Compare commits
247 Commits
v7.7.1
...
v8.2.2-rc.
Author | SHA1 | Date | |
---|---|---|---|
8a63648339 | |||
bb6b026720 | |||
2a13b314dc | |||
4506b3f6d4 | |||
804abef0de | |||
7e932b920e | |||
46fdfbc507 | |||
a4ff8607c5 | |||
7fe4eeda57 | |||
9f25cddaa4 | |||
eb64fe60d0 | |||
36f404e17d | |||
5398590939 | |||
96d5cfea14 | |||
79007ebd55 | |||
fcb519dac3 | |||
2b487aa959 | |||
733feadcb2 | |||
5ae568f19c | |||
0e51807856 | |||
b6eb165207 | |||
d26dbf4b3d | |||
a722582709 | |||
de64deb5c5 | |||
402c6fc64a | |||
a1f934466c | |||
15aa114579 | |||
b9cc82e37d | |||
8af9c8b150 | |||
7dcc985222 | |||
9c2bc19897 | |||
801b08359a | |||
c469dee158 | |||
2aa7eda1e9 | |||
f1246c9e00 | |||
2749cbe4d1 | |||
d2a9280ab3 | |||
8e25ee0fc9 | |||
55026f913b | |||
7cc40e2453 | |||
cb6b19952d | |||
983af57842 | |||
3c2820dc31 | |||
1c25b69160 | |||
641cc38ae4 | |||
cd68b07e19 | |||
2b252c9abb | |||
e2e8370bb9 | |||
e9e31394c4 | |||
2825ccbcd5 | |||
377a14ff72 | |||
a83997b9b4 | |||
3e155d8e80 | |||
6953b72ee6 | |||
ab370a1dda | |||
20845e5860 | |||
9ed3257006 | |||
2221e30c0a | |||
ce43dca23b | |||
4acf5d0931 | |||
b742ed73aa | |||
5156b2e0cc | |||
6b81cf4a24 | |||
cca3a68fe4 | |||
adb2904872 | |||
d68b8d03dd | |||
e7afb88f22 | |||
48cbfc64c0 | |||
0b067858bc | |||
2d44d98f17 | |||
74ef3096ae | |||
8f1163fd75 | |||
a240d503c5 | |||
e331a641b2 | |||
85db4b8e0a | |||
0aa139cf4a | |||
4140ca6fbd | |||
a8ce9da9ee | |||
476a33c0ab | |||
8e868c9fda | |||
17c8b1a172 | |||
b374c04d73 | |||
e750d824ad | |||
dd4c50c3eb | |||
20cc869299 | |||
7214dbccdb | |||
e6cebdd546 | |||
0301003ccf | |||
de2efe0c01 | |||
90d765d7f6 | |||
3e0a1721ce | |||
7214fbcd4c | |||
4b8aecfe91 | |||
387c71c0aa | |||
8d5ce21df4 | |||
f6dfcfbae9 | |||
69e9595db9 | |||
de390678fd | |||
cf9a7b8c60 | |||
73e9c16a8d | |||
9775623981 | |||
20b7bb3c99 | |||
3defc16658 | |||
0dbe592182 | |||
a7c0e5bdaa | |||
3b051cbbea | |||
f7edfd1c29 | |||
b182c43965 | |||
4639e7ad2e | |||
56241203a0 | |||
8c8540de5d | |||
b45af78322 | |||
98bcf3bf7e | |||
e28bcfced3 | |||
a5bd990245 | |||
58301e0844 | |||
c9213fb6cd | |||
641819a364 | |||
3ee3a8b41d | |||
5600403088 | |||
3b00bace23 | |||
fcba470aad | |||
206e602d73 | |||
f98d1aaade | |||
310f857257 | |||
a2b1055094 | |||
f23ddecef3 | |||
54687ec3c0 | |||
545f0fcea5 | |||
5db8ce3043 | |||
ed48669ae1 | |||
69c3befb2d | |||
fc39e837ea | |||
6df9f28c02 | |||
f3d0947427 | |||
3326a4cf2a | |||
9a6ea87b0c | |||
12179d0ec0 | |||
d4141fc51e | |||
c32ab6181c | |||
3847882599 | |||
4db157f663 | |||
351b4e84c9 | |||
0c65561bcb | |||
00200f75a0 | |||
58614a74f5 | |||
f3d64663a0 | |||
8be8c270f8 | |||
a56037f1c9 | |||
2ff7955ec3 | |||
f2044c4d26 | |||
4113f0faea | |||
bacd09484a | |||
8253eb62bd | |||
70b659a0a0 | |||
79ed74ab17 | |||
93bb3ebd69 | |||
e8e516159c | |||
1431c031a0 | |||
209c2183e1 | |||
0c98c282a0 | |||
58c10796a1 | |||
603e6a99f3 | |||
6622ebc04e | |||
465dbb4a8d | |||
08ae908453 | |||
c35a1e7c50 | |||
ecb22c3829 | |||
eebf969f7e | |||
5816f25c3e | |||
b2a81d880d | |||
b10c1476a6 | |||
e11cd09a12 | |||
27575eda68 | |||
f33b9a1ec6 | |||
7c45fff7ba | |||
ecdb0775cd | |||
6ef90a56ed | |||
71b86ff43b | |||
0535e06ae1 | |||
6261f5e7cc | |||
f256b74929 | |||
4f1182a230 | |||
e7c20547f8 | |||
9ab4c510fe | |||
7d78c52064 | |||
6223d91291 | |||
840b5e1312 | |||
e69813f6e3 | |||
3c0c057e06 | |||
984d12b3f2 | |||
61dc54f115 | |||
34e47cccc1 | |||
c170345550 | |||
1e40706f72 | |||
ea1a747ebf | |||
a14e967020 | |||
0fff10d2c6 | |||
7c2123614d | |||
d149866703 | |||
18039140db | |||
4de9599018 | |||
bb85829d71 | |||
ff077943ec | |||
f057114bcc | |||
e7bfe7f80d | |||
18112a97ab | |||
8ee6fb58ac | |||
08831fc31d | |||
c5c25394fb | |||
2f649c9866 | |||
91c5dd40fa | |||
e95e688cf0 | |||
9845f1de08 | |||
07032d312d | |||
ccb5d32763 | |||
bf83e4b03b | |||
03b491763f | |||
3abc9edf0e | |||
f9accc51d3 | |||
c3bade81b4 | |||
6edd1f00dd | |||
02be899629 | |||
8e043f289a | |||
30fecf8578 | |||
1112da33e3 | |||
ffa65e871e | |||
f49c7b465b | |||
e6f75156ec | |||
ebafeb19ad | |||
5166c719c4 | |||
bf92ea8340 | |||
cf1e595ba2 | |||
2bf3296c0f | |||
11513f73b7 | |||
b6f60c6835 | |||
e9d276010f | |||
b08c4b0b29 | |||
d684807d96 | |||
9a60ef7c47 | |||
cc446059de | |||
d75b809c13 | |||
9fc3998cf7 | |||
10965b82a9 | |||
86884607ef | |||
1ff0449332 | |||
564ffc2be9 |
@ -131,6 +131,52 @@
|
||||
"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"
|
||||
]
|
||||
}
|
||||
],
|
||||
"contributorsPerLine": 7,
|
||||
@ -138,5 +184,6 @@
|
||||
"repoType": "github",
|
||||
"repoHost": "https://github.com",
|
||||
"projectName": "keycloakify",
|
||||
"projectOwner": "keycloakify"
|
||||
"projectOwner": "keycloakify",
|
||||
"commitType": "docs"
|
||||
}
|
||||
|
6
.github/workflows/ci.yaml
vendored
6
.github/workflows/ci.yaml
vendored
@ -3,6 +3,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- v*
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
@ -34,7 +35,7 @@ jobs:
|
||||
- uses: bahmutov/npm-install@v1
|
||||
- run: yarn build
|
||||
- run: yarn test
|
||||
- run: yarn test:keycloakify-starter
|
||||
#- run: yarn test:keycloakify-starter
|
||||
|
||||
storybook:
|
||||
runs-on: ubuntu-latest
|
||||
@ -44,7 +45,7 @@ jobs:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '16'
|
||||
node-version: '18'
|
||||
- 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,7 +75,6 @@ jobs:
|
||||
id: step1
|
||||
with:
|
||||
action_name: is_package_json_version_upgraded
|
||||
branch: ${{ github.head_ref || github.ref }}
|
||||
|
||||
create_github_release:
|
||||
runs-on: ubuntu-latest
|
||||
|
4
.gitignore
vendored
4
.gitignore
vendored
@ -53,5 +53,9 @@ jspm_packages
|
||||
|
||||
# VS Code devcontainers
|
||||
.devcontainer
|
||||
/.yarn
|
||||
/.yarnrc.yml
|
||||
|
||||
/stories/assets/fonts/
|
||||
/build_storybook/
|
||||
/storybook-static/
|
||||
|
@ -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/
|
||||
/keycloakify_starter_test/
|
||||
/.storybook/static/keycloak-resources/
|
@ -3,8 +3,9 @@ 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 const DocsContainer = ({ children, context }) => {
|
||||
export function DocsContainer({ children, context }) {
|
||||
const isStorybookUiDark = useDarkMode();
|
||||
|
||||
const theme = isStorybookUiDark ? darkTheme : lightTheme;
|
||||
@ -15,7 +16,7 @@ export const DocsContainer = ({ children, context }) => {
|
||||
<>
|
||||
<style>{`
|
||||
body {
|
||||
padding: 0 !important,
|
||||
padding: 0 !important;
|
||||
background-color: ${backgroundColor};
|
||||
}
|
||||
|
||||
@ -57,4 +58,19 @@ export const DocsContainer = ({ children, context }) => {
|
||||
</BaseContainer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export function CanvasContainer({ children }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{`
|
||||
body {
|
||||
padding: 0 !important;
|
||||
}
|
||||
`}</style>
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
|
||||
}
|
@ -1,8 +1,6 @@
|
||||
module.exports = {
|
||||
"stories": [
|
||||
"../stories/*.stories.mdx",
|
||||
"../stories/*.stories.@(ts|tsx)",
|
||||
"../stories/**/*.stories.@(ts|tsx)"
|
||||
"../stories/**/*.stories.@(ts|tsx|mdx)"
|
||||
],
|
||||
"addons": [
|
||||
"@storybook/addon-links",
|
||||
|
6
.storybook/manager.js
Normal file
6
.storybook/manager.js
Normal file
@ -0,0 +1,6 @@
|
||||
import { addons } from '@storybook/addons';
|
||||
|
||||
addons.setConfig({
|
||||
"selectedPanel": 'storybook/a11y/panel',
|
||||
"showPanel": false,
|
||||
});
|
@ -1,5 +1,5 @@
|
||||
import { darkTheme, lightTheme } from "./customTheme";
|
||||
import { DocsContainer } from "./DocsContainer";
|
||||
import { DocsContainer, CanvasContainer } from "./Containers";
|
||||
|
||||
export const parameters = {
|
||||
"actions": { "argTypesRegex": "^on[A-Z].*" },
|
||||
@ -17,6 +17,12 @@ export const parameters = {
|
||||
"docs": {
|
||||
"container": DocsContainer
|
||||
},
|
||||
"controls": {
|
||||
"disable": true,
|
||||
},
|
||||
"actions": {
|
||||
"disable": true
|
||||
},
|
||||
"viewport": {
|
||||
"viewports": {
|
||||
"1440p": {
|
||||
@ -68,7 +74,7 @@ export const parameters = {
|
||||
"height": "844px",
|
||||
},
|
||||
},
|
||||
"iphone5se":{
|
||||
"iphone5se": {
|
||||
"name": "Iphone 5/SE",
|
||||
"styles": {
|
||||
"width": "320px",
|
||||
@ -97,16 +103,23 @@ export const parameters = {
|
||||
},
|
||||
};
|
||||
|
||||
export const decorators = [
|
||||
(Story) => (
|
||||
<CanvasContainer>
|
||||
<Story />
|
||||
</CanvasContainer>
|
||||
),
|
||||
];
|
||||
|
||||
const { getHardCodedWeight } = (() => {
|
||||
|
||||
const orderedPagesPrefix = [
|
||||
"Introduction",
|
||||
//"components/Header",
|
||||
//"components/Footer",
|
||||
"components/Alert",
|
||||
"components/Tabs",
|
||||
"components/Stepper",
|
||||
"components/Button",
|
||||
"login/login.ftl",
|
||||
"login/register-user-profile.ftl",
|
||||
"login/register.ftl",
|
||||
"login/terms.ftl",
|
||||
"login/error.ftl",
|
||||
];
|
||||
|
||||
function getHardCodedWeight(kind) {
|
||||
|
@ -1 +1 @@
|
||||
react-dsfr-components.etalab.studio
|
||||
storybook.keycloakify.dev
|
156
README.md
156
README.md
@ -20,43 +20,70 @@
|
||||
<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">Storybook</a>
|
||||
<a href="https://storybook.keycloakify.dev">Storybook</a>
|
||||
-
|
||||
<a href="https://github.com/codegouvfr/keycloakify-starter">Starter project</a>
|
||||
</p>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<i>Ultimately this build tool generates a Keycloak theme <a href="https://www.keycloakify.dev">Learn more</a></i>
|
||||
<i>This build tool generates a Keycloak theme <a href="https://www.keycloakify.dev">Learn more</a></i>
|
||||
<img src="https://user-images.githubusercontent.com/6702424/110260457-a1c3d380-7fac-11eb-853a-80459b65626b.png">
|
||||
</p>
|
||||
|
||||
> Whether or not React is your preferred framework, Keycloakify
|
||||
> offers a solid option for building Keycloak themes.
|
||||
> It's not just a convenient way to create a Keycloak theme
|
||||
> when using React; it's a well-regarded solution that many
|
||||
> developers appreciate.
|
||||
|
||||
> 📣 🛑 Account themes generated by Keycloakify are not currently compatible with Keycloak 22.
|
||||
> We are working on a solution. [Follow progress](https://github.com/keycloakify/keycloakify/issues/389).
|
||||
> Login and email themes are not affected.
|
||||
> UPDATE: [The PR](https://github.com/keycloak/keycloak/pull/22317) that should future proof Keycloakify account themes has been greenlighted
|
||||
> by the Keycloak team. Resolution is only a matter of time.
|
||||
|
||||
Keycloakify is fully compatible with Keycloak, starting from version 11 and is anticipated to maintain compatibility with all future versions.
|
||||
You can update your Keycloak, your Keycloakify generated theme won't break.
|
||||
To understand the basis of my confidence in this, you can [visit this discussion thread where I've explained in detail](https://github.com/keycloakify/keycloakify/discussions/346#discussioncomment-5889791).
|
||||
|
||||
## Sponsor 👼
|
||||
|
||||
We are exclusively sponsored by [Cloud IAM](https://www.cloud-iam.com), a French company offering Keycloak as a service.
|
||||
We are exclusively sponsored by [Cloud IAM](https://cloud-iam.com/?mtm_campaign=keycloakify-deal&mtm_source=keycloakify-github), a French company offering Keycloak as a service.
|
||||
Their dedicated support helps us continue the development and maintenance of this project.
|
||||
|
||||
[Cloud IAM](https://www.cloud-iam.com/) provides the following services:
|
||||
[Cloud IAM](https://cloud-iam.com/?mtm_campaign=keycloakify-deal&mtm_source=keycloakify-github) provides the following services:
|
||||
|
||||
- Perfectly configured and optimized Keycloak IAM, ready in seconds.
|
||||
- Simplify and secure your Keycloak Identity and Access Management. Keycloak as a Service.
|
||||
- Custom theme building for your brand using Keycloakify.
|
||||
|
||||
<div align="center">
|
||||
|
||||

|
||||
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
|
||||

|
||||
|
||||
</div>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.cloud-iam.com/">
|
||||
<img src="https://user-images.githubusercontent.com/6702424/232165752-17134e68-4a55-4d6e-8672-e9132ecac5d5.svg" alt="Cloud IAM Logo" width="200"/>
|
||||
</a>
|
||||
<br/>
|
||||
<i>Use promo code <code>keycloakify</code> </i>
|
||||
<i>Checkout <a href="https://cloud-iam.com/?mtm_campaign=keycloakify-deal&mtm_source=keycloakify-github">Cloud IAM</a> and use promo code <code>keycloakify5</code></i>
|
||||
<br/>
|
||||
<i>5% of your annual subscription will be donated to us, and you'll get 5% off too.</i>
|
||||
</p>
|
||||
|
||||
Thank you, [Cloud IAM](https://www.cloud-iam.com/), for your support!
|
||||
Thank you, [Cloud IAM](https://cloud-iam.com/?mtm_campaign=keycloakify-deal&mtm_source=keycloakify-github), for your support!
|
||||
|
||||
## Contributors ✨
|
||||
|
||||
@ -83,7 +110,14 @@ 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>
|
||||
<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>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@ -93,7 +127,103 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
|
||||
|
||||
<!-- ALL-CONTRIBUTORS-LIST:END -->
|
||||
|
||||
## Changelog highlights
|
||||
# Changelog highlights
|
||||
|
||||
## 8.0
|
||||
|
||||
- Much smaller .jar size. 70.2 MB -> 7.8 MB.
|
||||
Keycloakify now detects which of the static resources from the default theme are actually used by your theme and only include those in the .jar.
|
||||
- Build time: The first build is slowed but the subsequent build are faster. [Update your CI so that the cache is persisted across CI build](https://github.com/keycloakify/keycloakify-starter/commit/bc378d5afb67e796f520afbc348185f3e319d9d0).
|
||||
|
||||
### Breaking changes
|
||||
|
||||
There are very few breaking changes in this major version.
|
||||
|
||||
- The [`--external-assets` build option has been removed](https://docs.keycloakify.dev/v/v7/build-options#external-assets-deprecated) it was a performance optimization that is no longer relevant now that
|
||||
we have lazy loading.
|
||||
|
||||
- `kcContext.usernameEditDisabled` is now `kcContext.usernameHidden`, the type was lying, it has been updated to reflect what's actually on the `kcContext` at runtime.
|
||||
If you want to see in detail what should be updated [see issue](https://github.com/keycloakify/keycloakify/pull/399), or you can search and replace `usernameEditDisabled` -> `usernameHidden` it'll do the trick.
|
||||
|
||||
- The `usePrepareTemplate` prototype has been changed, you can search and replace:
|
||||
|
||||
`src/keycloak-theme/login/Template.tsx`
|
||||
|
||||
```ts
|
||||
url,
|
||||
"stylesCommon": [
|
||||
"node_modules/patternfly/dist/css/patternfly.min.css",
|
||||
"node_modules/patternfly/dist/css/patternfly-additions.min.css",
|
||||
"lib/zocial/zocial.css"
|
||||
],
|
||||
"styles": ["css/login.css"],
|
||||
```
|
||||
|
||||
by
|
||||
|
||||
```ts
|
||||
"styles": [
|
||||
`${url.resourcesCommonPath}/node_modules/patternfly/dist/css/patternfly.min.css`,
|
||||
`${url.resourcesCommonPath}/node_modules/patternfly/dist/css/patternfly-additions.min.css`,
|
||||
`${url.resourcesCommonPath}/lib/zocial/zocial.css`,
|
||||
`${url.resourcesPath}/css/login.css`
|
||||
],
|
||||
```
|
||||
|
||||
and
|
||||
|
||||
`src/keycloak-theme/account/Template.css`
|
||||
|
||||
```ts
|
||||
url,
|
||||
"stylesCommon": ["node_modules/patternfly/dist/css/patternfly.min.css", "node_modules/patternfly/dist/css/patternfly-additions.min.css"],
|
||||
"styles": ["css/account.css"],
|
||||
```
|
||||
|
||||
by
|
||||
|
||||
```ts
|
||||
"styles": [
|
||||
`${url.resourcesCommonPath}/node_modules/patternfly/dist/css/patternfly.min.css`,
|
||||
`${url.resourcesCommonPath}/node_modules/patternfly/dist/css/patternfly-additions.min.css`,
|
||||
`${url.resourcesPath}/css/account.css`
|
||||
],
|
||||
```
|
||||
|
||||
## 7.15
|
||||
|
||||
- The i18n messages you defines in your theme are now also maid available to Keycloak.
|
||||
In practice this mean that you can now customize the `kcContext.message.summary` that
|
||||
display a general alert and the values returned by `kcContext.messagesPerField.get()` that
|
||||
are used to display specific error on some field of the form.
|
||||
[See video](https://youtu.be/D6tZcemReTI)
|
||||
|
||||
## 7.14
|
||||
|
||||
- Deprecate the `extraPages` build option. Keycloakify is now able to analyze your code to detect extra pages.
|
||||
|
||||
## 7.13
|
||||
|
||||
- Deprecate `customUserAttribute`, Keycloakify now analyze your code to predict field name usage. [See doc](https://docs.keycloakify.dev/build-options#customuserattributes).
|
||||
It's now mandatory to [adopt the new directory structure](https://docs.keycloakify.dev/migration-guides/v6-greater-than-v7).
|
||||
|
||||
## 7.12
|
||||
|
||||
- You can now pack multiple themes variant in a single `.jar` bundle. In vanilla Keycloak themes you have the ability to extend a base theme.
|
||||
There is now an idiomatic way of achieving the same result. [Learn more](https://docs.keycloakify.dev/build-options#keycloakify.extrathemenames).
|
||||
|
||||
## 7.9
|
||||
|
||||
- Separate script for copying the default theme static assets to the public directory.
|
||||
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
|
||||
|
||||
- Better storybook support, see [the starter project](https://github.com/keycloakify/keycloakify-starter).
|
||||
|
||||
## 7.0 🍾
|
||||
|
||||
|
@ -30,18 +30,6 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"extraLoginPages": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"extraAccountPages": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"extraThemeProperties": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
@ -70,12 +58,6 @@
|
||||
"keycloakifyBuildDirPath": {
|
||||
"type": "string"
|
||||
},
|
||||
"customUserAttributes": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"themeName": {
|
||||
"type": "string"
|
||||
}
|
||||
|
36
package.json
36
package.json
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "keycloakify",
|
||||
"version": "7.7.0",
|
||||
"version": "8.2.2-rc.0",
|
||||
"description": "Create Keycloak themes using React",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@ -9,9 +9,8 @@
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"scripts": {
|
||||
"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\")",
|
||||
"prepare": "yarn generate-i18n-messages",
|
||||
"build": "rimraf dist/ && tsc -p src/bin && tsc -p src && tsc-alias -p src/tsconfig.json && yarn grant-exec-perms && yarn copy-files dist/ && cp -r src dist/",
|
||||
"generate:json-schema": "ts-node scripts/generate-json-schema.ts",
|
||||
"grant-exec-perms": "node dist/bin/tools/grant-exec-perms.js",
|
||||
"copy-files": "copyfiles -u 1 src/**/*.ftl",
|
||||
@ -24,16 +23,17 @@
|
||||
"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",
|
||||
"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"
|
||||
"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"
|
||||
},
|
||||
"bin": {
|
||||
"keycloakify": "dist/bin/keycloakify/index.js",
|
||||
"initialize-email-theme": "dist/bin/initialize-email-theme.js",
|
||||
"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"
|
||||
"eject-keycloak-page": "dist/bin/eject-keycloak-page.js",
|
||||
"initialize-email-theme": "dist/bin/initialize-email-theme.js",
|
||||
"keycloakify": "dist/bin/keycloakify/index.js"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{ts,tsx,json,md}": [
|
||||
@ -69,6 +69,7 @@
|
||||
},
|
||||
"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",
|
||||
@ -78,17 +79,20 @@
|
||||
"@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",
|
||||
"concurrently": "^7.6.0",
|
||||
"@types/yazl": "^2.4.2",
|
||||
"concurrently": "^8.0.1",
|
||||
"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",
|
||||
@ -98,13 +102,16 @@
|
||||
"storybook-dark-mode": "^1.1.2",
|
||||
"ts-node": "^10.9.1",
|
||||
"tsc-alias": "^1.8.3",
|
||||
"typescript": "^5.0.4",
|
||||
"tss-react": "^4.8.2",
|
||||
"typescript": "^4.9.1-beta",
|
||||
"vitest": "^0.29.8",
|
||||
"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",
|
||||
@ -113,6 +120,7 @@
|
||||
"minimist": "^1.2.6",
|
||||
"path-browserify": "^1.0.1",
|
||||
"react-markdown": "^5.0.3",
|
||||
"recast": "^0.23.3",
|
||||
"rfc4648": "^1.5.2",
|
||||
"tsafe": "^1.6.0",
|
||||
"yauzl": "^2.10.0",
|
||||
|
@ -4,7 +4,6 @@ import { join as pathJoin, relative as pathRelative, dirname as pathDirname, sep
|
||||
import { crawl } from "../src/bin/tools/crawl";
|
||||
import { downloadBuiltinKeycloakTheme } from "../src/bin/download-builtin-keycloak-theme";
|
||||
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,
|
||||
@ -13,7 +12,8 @@ import { getLogger } from "../src/bin/tools/logger";
|
||||
//@ts-ignore
|
||||
const propertiesParser = require("properties-parser");
|
||||
|
||||
const { isSilent } = getCliOptions(process.argv.slice(2));
|
||||
const isSilent = true;
|
||||
|
||||
const logger = getLogger({ isSilent });
|
||||
|
||||
async function main() {
|
||||
@ -24,9 +24,9 @@ async function main() {
|
||||
fs.rmSync(tmpDirPath, { "recursive": true, "force": true });
|
||||
|
||||
await downloadBuiltinKeycloakTheme({
|
||||
"projectDirPath": getProjectRoot(),
|
||||
keycloakVersion,
|
||||
"destDirPath": tmpDirPath,
|
||||
isSilent
|
||||
"destDirPath": tmpDirPath
|
||||
});
|
||||
|
||||
type Dictionary = { [idiomId: string]: string };
|
||||
@ -37,7 +37,10 @@ async function main() {
|
||||
const baseThemeDirPath = pathJoin(tmpDirPath, "base");
|
||||
const re = new RegExp(`^([^\\${pathSep}]+)\\${pathSep}messages\\${pathSep}messages_([^.]+).properties$`);
|
||||
|
||||
crawl(baseThemeDirPath).forEach(filePath => {
|
||||
crawl({
|
||||
"dirPath": baseThemeDirPath,
|
||||
"returnedPathsType": "relative to dirPath"
|
||||
}).forEach(filePath => {
|
||||
const match = filePath.match(re);
|
||||
|
||||
if (match === null) {
|
||||
|
@ -17,10 +17,12 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
|
||||
|
||||
const { isReady } = usePrepareTemplate({
|
||||
"doFetchDefaultThemeResources": doUseDefaultCss,
|
||||
url,
|
||||
"stylesCommon": ["node_modules/patternfly/dist/css/patternfly.min.css", "node_modules/patternfly/dist/css/patternfly-additions.min.css"],
|
||||
"styles": ["css/account.css"],
|
||||
"htmlClassName": undefined,
|
||||
"styles": [
|
||||
`${url.resourcesCommonPath}/node_modules/patternfly/dist/css/patternfly.min.css`,
|
||||
`${url.resourcesCommonPath}/node_modules/patternfly/dist/css/patternfly-additions.min.css`,
|
||||
`${url.resourcesPath}/css/account.css`
|
||||
],
|
||||
"htmlClassName": getClassName("kcHtmlClass"),
|
||||
"bodyClassName": clsx("admin-console", "user", getClassName("kcBodyClass"))
|
||||
});
|
||||
|
||||
@ -60,7 +62,7 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
|
||||
</div>
|
||||
</li>
|
||||
)}
|
||||
{referrer?.url !== undefined && (
|
||||
{referrer?.url && (
|
||||
<li>
|
||||
<a href={referrer.url} id="referrer">
|
||||
{msg("backTo", referrer.name)}
|
||||
|
@ -11,4 +11,4 @@ export type TemplateProps<KcContext extends KcContext.Common, I18nExtended exten
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export type ClassKey = "kcBodyClass" | "kcButtonClass" | "kcButtonPrimaryClass" | "kcButtonLargeClass" | "kcButtonDefaultClass";
|
||||
export type ClassKey = "kcHtmlClass" | "kcBodyClass" | "kcButtonClass" | "kcButtonPrimaryClass" | "kcButtonLargeClass" | "kcButtonDefaultClass";
|
||||
|
@ -211,7 +211,9 @@ const keycloakifyExtraMessages = {
|
||||
"shouldBeDifferent": "{0} should be different to {1}",
|
||||
"shouldMatchPattern": "Pattern should match: `/{0}/`",
|
||||
"mustBeAnInteger": "Must be an integer",
|
||||
"notAValidOption": "Not a valid option"
|
||||
"notAValidOption": "Not a valid option",
|
||||
"newPasswordSameAsOld": "New password must be different from the old one",
|
||||
"passwordConfirmNotMatch": "Password confirmation does not match"
|
||||
},
|
||||
"fr": {
|
||||
/* spell-checker: disable */
|
||||
@ -223,7 +225,9 @@ const keycloakifyExtraMessages = {
|
||||
|
||||
"logoutConfirmTitle": "Déconnexion",
|
||||
"logoutConfirmHeader": "Êtes-vous sûr(e) de vouloir vous déconnecter ?",
|
||||
"doLogout": "Se déconnecter"
|
||||
"doLogout": "Se déconnecter",
|
||||
"newPasswordSameAsOld": "Le nouveau mot de passe doit être différent de l'ancien",
|
||||
"passwordConfirmNotMatch": "La confirmation du mot de passe ne correspond pas"
|
||||
/* spell-checker: enable */
|
||||
}
|
||||
};
|
||||
|
@ -4,6 +4,7 @@ export default Fallback;
|
||||
|
||||
export { getKcContext } from "keycloakify/account/kcContext/getKcContext";
|
||||
export { createGetKcContext } from "keycloakify/account/kcContext/createGetKcContext";
|
||||
export type { AccountThemePageId as PageId } from "keycloakify/bin/keycloakify/generateFtl";
|
||||
export { createUseI18n } from "keycloakify/account/i18n/i18n";
|
||||
|
||||
export type { PageProps } from "keycloakify/account/pages/PageProps";
|
||||
|
@ -1,4 +1,4 @@
|
||||
import type { AccountThemePageId } from "keycloakify/bin/keycloakify/generateFtl";
|
||||
import type { AccountThemePageId, ThemeType } from "keycloakify/bin/keycloakify/generateFtl";
|
||||
import { assert } from "tsafe/assert";
|
||||
import type { Equals } from "tsafe";
|
||||
|
||||
@ -7,6 +7,8 @@ export type KcContext = KcContext.Password | KcContext.Account;
|
||||
export declare namespace KcContext {
|
||||
export type Common = {
|
||||
keycloakifyVersion: string;
|
||||
themeType: "account";
|
||||
themeName: string;
|
||||
locale?: {
|
||||
supported: {
|
||||
url: string;
|
||||
@ -26,6 +28,8 @@ 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: {
|
||||
@ -38,18 +42,44 @@ export declare namespace KcContext {
|
||||
internationalizationEnabled: boolean;
|
||||
userManagedAccessAllowed: boolean;
|
||||
};
|
||||
// Present only if redirected to account page with ?referrer=xxx&referrer_uri=http...
|
||||
message?: {
|
||||
type: "success" | "warning" | "error" | "info";
|
||||
summary: string;
|
||||
};
|
||||
referrer?: {
|
||||
url?: string;
|
||||
name: string;
|
||||
url: string; // The url of the App
|
||||
name: string; // Client id
|
||||
};
|
||||
messagesPerField: {
|
||||
printIfExists: <T>(fieldName: string, x: T) => T | undefined;
|
||||
/**
|
||||
* Return text if message for given field exists. Useful eg. to add css styles for fields with message.
|
||||
*
|
||||
* @param fieldName to check for
|
||||
* @param text to return
|
||||
* @return text if message exists for given field, else undefined
|
||||
*/
|
||||
printIfExists: <T extends string>(fieldName: string, text: T) => T | undefined;
|
||||
/**
|
||||
* Check if exists error message for given fields
|
||||
*
|
||||
* @param fields
|
||||
* @return boolean
|
||||
*/
|
||||
existsError: (fieldName: string) => boolean;
|
||||
/**
|
||||
* Get message for given field.
|
||||
*
|
||||
* @param fieldName
|
||||
* @return message text or empty string
|
||||
*/
|
||||
get: (fieldName: string) => string;
|
||||
/**
|
||||
* Check if message for given field exists
|
||||
*
|
||||
* @param field
|
||||
* @return boolean
|
||||
*/
|
||||
exists: (fieldName: string) => boolean;
|
||||
};
|
||||
account: {
|
||||
@ -71,7 +101,6 @@ export declare namespace KcContext {
|
||||
export type Account = Common & {
|
||||
pageId: "account.ftl";
|
||||
url: {
|
||||
referrerURI: string;
|
||||
accountUrl: string;
|
||||
};
|
||||
realm: {
|
||||
@ -82,4 +111,15 @@ export declare namespace KcContext {
|
||||
};
|
||||
}
|
||||
|
||||
assert<Equals<KcContext["pageId"], AccountThemePageId>>();
|
||||
{
|
||||
type Got = KcContext["pageId"];
|
||||
type Expected = AccountThemePageId;
|
||||
|
||||
type OnlyInGot = Exclude<Got, Expected>;
|
||||
type OnlyInExpected = Exclude<Expected, Got>;
|
||||
|
||||
assert<Equals<OnlyInGot, never>>();
|
||||
assert<Equals<OnlyInExpected, never>>();
|
||||
}
|
||||
|
||||
assert<KcContext["themeType"] extends ThemeType ? true : false>();
|
||||
|
@ -4,11 +4,9 @@ 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 { resourcesCommonDirPathRelativeToPublicDir } from "keycloakify/bin/mockTestingResourcesPath";
|
||||
import { symToStr } from "tsafe/symToStr";
|
||||
import { kcContextMocks, kcContextCommonMock } from "keycloakify/account/kcContext/kcContextMocks";
|
||||
import { id } from "tsafe/id";
|
||||
import { accountThemePageIds } from "keycloakify/bin/keycloakify/generateFtl/pageId";
|
||||
|
||||
export function createGetKcContext<KcContextExtension extends { pageId: string } = never>(params?: {
|
||||
mockData?: readonly DeepPartial<ExtendKcContext<KcContextExtension>>[];
|
||||
@ -30,13 +28,7 @@ export function createGetKcContext<KcContextExtension extends { pageId: string }
|
||||
if (mockPageId !== undefined && realKcContext === undefined) {
|
||||
//TODO maybe trow if no mock fo custom page
|
||||
|
||||
console.log(
|
||||
[
|
||||
`%cKeycloakify: ${symToStr({ mockPageId })} set to ${mockPageId}.`,
|
||||
`If assets are missing make sure you have built your Keycloak theme at least once.`
|
||||
].join(" "),
|
||||
"background: red; color: yellow; font-size: medium"
|
||||
);
|
||||
console.log(`%cKeycloakify: ${symToStr({ mockPageId })} set to ${mockPageId}.`, "background: red; color: yellow; font-size: medium");
|
||||
|
||||
const kcContextDefaultMock = kcContextMocks.find(({ pageId }) => pageId === mockPageId);
|
||||
|
||||
@ -93,14 +85,14 @@ export function createGetKcContext<KcContextExtension extends { pageId: string }
|
||||
return { "kcContext": undefined as any };
|
||||
}
|
||||
|
||||
if (id<readonly string[]>(accountThemePageIds).indexOf(realKcContext.pageId) < 0 && !("account" in realKcContext)) {
|
||||
if (realKcContext.themeType !== "account") {
|
||||
return { "kcContext": undefined as any };
|
||||
}
|
||||
|
||||
{
|
||||
const { url } = realKcContext;
|
||||
|
||||
url.resourcesCommonPath = pathJoin(url.resourcesPath, pathBasename(mockTestingResourcesCommonPath));
|
||||
url.resourcesCommonPath = pathJoin(url.resourcesPath, pathBasename(resourcesCommonDirPathRelativeToPublicDir));
|
||||
}
|
||||
|
||||
return { "kcContext": realKcContext as any };
|
||||
|
@ -2,7 +2,9 @@ import type { DeepPartial } from "keycloakify/tools/DeepPartial";
|
||||
import type { ExtendKcContext } from "./getKcContextFromWindow";
|
||||
import { createGetKcContext } from "./createGetKcContext";
|
||||
|
||||
/** @deprecated: Use createGetKcContext instead */
|
||||
/** NOTE: We now recommend using createGetKcContext instead of this function to make storybook integration easier
|
||||
* See: https://github.com/keycloakify/keycloakify-starter/blob/main/src/keycloak-theme/account/kcContext.ts
|
||||
*/
|
||||
export function getKcContext<KcContextExtension extends { pageId: string } = never>(params?: {
|
||||
mockPageId?: ExtendKcContext<KcContextExtension>["pageId"];
|
||||
mockData?: readonly DeepPartial<ExtendKcContext<KcContextExtension>>[];
|
||||
|
@ -1,16 +1,18 @@
|
||||
import "minimal-polyfills/Object.fromEntries";
|
||||
import { mockTestingResourcesCommonPath, mockTestingResourcesPath } from "keycloakify/bin/mockTestingResourcesPath";
|
||||
import { resourcesCommonDirPathRelativeToPublicDir, resourcesDirPathRelativeToPublicDir } from "keycloakify/bin/mockTestingResourcesPath";
|
||||
import { pathJoin } from "keycloakify/bin/tools/pathJoin";
|
||||
import { id } from "tsafe/id";
|
||||
import type { KcContext } from "./KcContext";
|
||||
|
||||
const PUBLIC_URL = process.env["PUBLIC_URL"] ?? "/";
|
||||
const PUBLIC_URL = (typeof process !== "object" ? undefined : process.env?.["PUBLIC_URL"]) || "/";
|
||||
|
||||
export const kcContextCommonMock: KcContext.Common = {
|
||||
"keycloakifyVersion": "0.0.0",
|
||||
"themeType": "account",
|
||||
"themeName": "my-theme-name",
|
||||
"url": {
|
||||
"resourcesPath": pathJoin(PUBLIC_URL, mockTestingResourcesPath),
|
||||
"resourcesCommonPath": pathJoin(PUBLIC_URL, mockTestingResourcesCommonPath),
|
||||
"resourcesPath": pathJoin(PUBLIC_URL, resourcesDirPathRelativeToPublicDir),
|
||||
"resourcesCommonPath": pathJoin(PUBLIC_URL, resourcesCommonDirPathRelativeToPublicDir),
|
||||
"resourceUrl": "#",
|
||||
"accountUrl": "#",
|
||||
"applicationsUrl": "#",
|
||||
@ -130,10 +132,6 @@ export const kcContextCommonMock: KcContext.Common = {
|
||||
],
|
||||
"currentLanguageTag": "en"
|
||||
},
|
||||
"message": {
|
||||
"type": "success",
|
||||
"summary": "This is a test message"
|
||||
},
|
||||
"features": {
|
||||
"authorization": true,
|
||||
"identityFederation": true,
|
||||
|
@ -3,6 +3,7 @@ import type { ClassKey } from "keycloakify/account/TemplateProps";
|
||||
|
||||
export const { useGetClassName } = createUseClassName<ClassKey>({
|
||||
"defaultClasses": {
|
||||
"kcHtmlClass": undefined,
|
||||
"kcBodyClass": undefined,
|
||||
"kcButtonClass": "btn",
|
||||
"kcButtonPrimaryClass": "btn-primary",
|
||||
|
@ -4,7 +4,7 @@ import { useGetClassName } from "keycloakify/account/lib/useGetClassName";
|
||||
import type { KcContext } from "../kcContext";
|
||||
import type { I18n } from "../i18n";
|
||||
|
||||
export default function LogoutConfirm(props: PageProps<Extract<KcContext, { pageId: "account.ftl" }>, I18n>) {
|
||||
export default function Account(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 LogoutConfirm(props: PageProps<Extract<KcContext, { page
|
||||
}
|
||||
});
|
||||
|
||||
const { url, realm, messagesPerField, stateChecker, account } = kcContext;
|
||||
const { url, realm, messagesPerField, stateChecker, account, referrer } = kcContext;
|
||||
|
||||
const { msg } = i18n;
|
||||
|
||||
@ -51,7 +51,7 @@ export default function LogoutConfirm(props: PageProps<Extract<KcContext, { page
|
||||
id="username"
|
||||
name="username"
|
||||
disabled={!realm.editUsernameAllowed}
|
||||
value={account.username ?? ""}
|
||||
defaultValue={account.username ?? ""}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -66,7 +66,7 @@ export default function LogoutConfirm(props: PageProps<Extract<KcContext, { page
|
||||
</div>
|
||||
|
||||
<div className="col-sm-10 col-md-10">
|
||||
<input type="text" className="form-control" id="email" name="email" autoFocus value={account.email ?? ""} />
|
||||
<input type="text" className="form-control" id="email" name="email" autoFocus defaultValue={account.email ?? ""} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -79,7 +79,7 @@ export default function LogoutConfirm(props: PageProps<Extract<KcContext, { page
|
||||
</div>
|
||||
|
||||
<div className="col-sm-10 col-md-10">
|
||||
<input type="text" className="form-control" id="firstName" name="firstName" value={account.firstName ?? ""} />
|
||||
<input type="text" className="form-control" id="firstName" name="firstName" defaultValue={account.firstName ?? ""} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -92,14 +92,14 @@ export default function LogoutConfirm(props: PageProps<Extract<KcContext, { page
|
||||
</div>
|
||||
|
||||
<div className="col-sm-10 col-md-10">
|
||||
<input type="text" className="form-control" id="lastName" name="lastName" value={account.lastName ?? ""} />
|
||||
<input type="text" className="form-control" id="lastName" name="lastName" defaultValue={account.lastName ?? ""} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<div id="kc-form-buttons" className="col-md-offset-2 col-md-10 submit">
|
||||
<div>
|
||||
{url.referrerURI !== undefined && <a href={url.referrerURI}>${msg("backToApplication")}</a>}
|
||||
{referrer !== undefined && <a href={referrer?.url}>{msg("backToApplication")}</a>}
|
||||
<button
|
||||
type="submit"
|
||||
className={clsx(
|
||||
|
@ -1,10 +1,11 @@
|
||||
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 LogoutConfirm(props: PageProps<Extract<KcContext, { pageId: "password.ftl" }>, I18n>) {
|
||||
export default function Password(props: PageProps<Extract<KcContext, { pageId: "password.ftl" }>, I18n>) {
|
||||
const { kcContext, i18n, doUseDefaultCss, Template, classes } = props;
|
||||
|
||||
const { getClassName } = useGetClassName({
|
||||
@ -17,10 +18,69 @@ export default function LogoutConfirm(props: PageProps<Extract<KcContext, { page
|
||||
|
||||
const { url, password, account, stateChecker } = kcContext;
|
||||
|
||||
const { msg } = i18n;
|
||||
const { msgStr, msg } = i18n;
|
||||
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [newPasswordConfirm, setNewPasswordConfirm] = useState("");
|
||||
const [newPasswordError, setNewPasswordError] = useState("");
|
||||
const [newPasswordConfirmError, setNewPasswordConfirmError] = useState("");
|
||||
const [hasNewPasswordBlurred, setHasNewPasswordBlurred] = useState(false);
|
||||
const [hasNewPasswordConfirmBlurred, setHasNewPasswordConfirmBlurred] = useState(false);
|
||||
|
||||
const checkNewPassword = (newPassword: string) => {
|
||||
if (!password.passwordSet) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword === currentPassword) {
|
||||
setNewPasswordError(msgStr("newPasswordSameAsOld"));
|
||||
} else {
|
||||
setNewPasswordError("");
|
||||
}
|
||||
};
|
||||
|
||||
const checkNewPasswordConfirm = (newPasswordConfirm: string) => {
|
||||
if (newPasswordConfirm === "") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword !== newPasswordConfirm) {
|
||||
setNewPasswordConfirmError(msgStr("passwordConfirmNotMatch"));
|
||||
} else {
|
||||
setNewPasswordConfirmError("");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Template {...{ kcContext, i18n, doUseDefaultCss, classes }} active="password">
|
||||
<Template
|
||||
{...{
|
||||
kcContext: {
|
||||
...kcContext,
|
||||
"message": (() => {
|
||||
if (newPasswordError !== "") {
|
||||
return {
|
||||
"type": "error",
|
||||
"summary": newPasswordError
|
||||
};
|
||||
}
|
||||
|
||||
if (newPasswordConfirmError !== "") {
|
||||
return {
|
||||
"type": "error",
|
||||
"summary": newPasswordConfirmError
|
||||
};
|
||||
}
|
||||
|
||||
return kcContext.message;
|
||||
})()
|
||||
},
|
||||
i18n,
|
||||
doUseDefaultCss,
|
||||
classes
|
||||
}}
|
||||
active="password"
|
||||
>
|
||||
<div className="row">
|
||||
<div className="col-md-10">
|
||||
<h2>{msg("changePasswordHtmlTitle")}</h2>
|
||||
@ -48,9 +108,17 @@ export default function LogoutConfirm(props: PageProps<Extract<KcContext, { page
|
||||
{msg("password")}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="col-sm-10 col-md-10">
|
||||
<input type="password" className="form-control" id="password" name="password" autoFocus autoComplete="current-password" />
|
||||
<input
|
||||
type="password"
|
||||
className="form-control"
|
||||
id="password"
|
||||
name="password"
|
||||
autoFocus
|
||||
autoComplete="current-password"
|
||||
value={currentPassword}
|
||||
onChange={event => setCurrentPassword(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@ -63,9 +131,27 @@ export default function LogoutConfirm(props: PageProps<Extract<KcContext, { page
|
||||
{msg("passwordNew")}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="col-sm-10 col-md-10">
|
||||
<input type="password" className="form-control" id="password-new" name="password-new" autoComplete="new-password" />
|
||||
<input
|
||||
type="password"
|
||||
className="form-control"
|
||||
id="password-new"
|
||||
name="password-new"
|
||||
autoComplete="new-password"
|
||||
value={newPassword}
|
||||
onChange={event => {
|
||||
const newPassword = event.target.value;
|
||||
|
||||
setNewPassword(newPassword);
|
||||
if (hasNewPasswordBlurred) {
|
||||
checkNewPassword(newPassword);
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
setHasNewPasswordBlurred(true);
|
||||
checkNewPassword(newPassword);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -77,7 +163,26 @@ export default function LogoutConfirm(props: PageProps<Extract<KcContext, { page
|
||||
</div>
|
||||
|
||||
<div className="col-sm-10 col-md-10">
|
||||
<input type="password" className="form-control" id="password-confirm" name="password-confirm" autoComplete="new-password" />
|
||||
<input
|
||||
type="password"
|
||||
className="form-control"
|
||||
id="password-confirm"
|
||||
name="password-confirm"
|
||||
autoComplete="new-password"
|
||||
value={newPasswordConfirm}
|
||||
onChange={event => {
|
||||
const newPasswordConfirm = event.target.value;
|
||||
|
||||
setNewPasswordConfirm(newPasswordConfirm);
|
||||
if (hasNewPasswordConfirmBlurred) {
|
||||
checkNewPasswordConfirm(newPasswordConfirm);
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
setHasNewPasswordConfirmBlurred(true);
|
||||
checkNewPasswordConfirm(newPasswordConfirm);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -85,6 +190,7 @@ export default function LogoutConfirm(props: PageProps<Extract<KcContext, { page
|
||||
<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"),
|
||||
|
49
src/bin/copy-keycloak-resources-to-public.ts
Normal file
49
src/bin/copy-keycloak-resources-to-public.ts
Normal file
@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { downloadKeycloakStaticResources } from "./keycloakify/generateTheme/downloadKeycloakStaticResources";
|
||||
import { join as pathJoin, relative as pathRelative } from "path";
|
||||
import { basenameOfKeycloakDirInPublicDir } from "./mockTestingResourcesPath";
|
||||
import { readBuildOptions } from "./keycloakify/BuildOptions";
|
||||
import { themeTypes } from "./keycloakify/generateFtl";
|
||||
import * as fs from "fs";
|
||||
|
||||
(async () => {
|
||||
const projectDirPath = process.cwd();
|
||||
|
||||
const buildOptions = readBuildOptions({
|
||||
"processArgv": process.argv.slice(2),
|
||||
"projectDirPath": process.cwd()
|
||||
});
|
||||
|
||||
const keycloakDirInPublicDir = pathJoin(process.env["PUBLIC_DIR_PATH"] || pathJoin(projectDirPath, "public"), basenameOfKeycloakDirInPublicDir);
|
||||
|
||||
if (fs.existsSync(keycloakDirInPublicDir)) {
|
||||
console.log(`${pathRelative(projectDirPath, keycloakDirInPublicDir)} already exists.`);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const themeType of themeTypes) {
|
||||
await downloadKeycloakStaticResources({
|
||||
projectDirPath,
|
||||
"keycloakVersion": buildOptions.keycloakVersionDefaultAssets,
|
||||
"themeType": themeType,
|
||||
"themeDirPath": keycloakDirInPublicDir,
|
||||
"usedResources": undefined
|
||||
});
|
||||
}
|
||||
|
||||
fs.writeFileSync(
|
||||
pathJoin(keycloakDirInPublicDir, "README.txt"),
|
||||
Buffer.from(
|
||||
// prettier-ignore
|
||||
[
|
||||
"This is just a test folder that helps develop",
|
||||
"the login and register page without having to run a Keycloak container"
|
||||
].join(" ")
|
||||
)
|
||||
);
|
||||
|
||||
fs.writeFileSync(pathJoin(keycloakDirInPublicDir, ".gitignore"), Buffer.from("*", "utf8"));
|
||||
|
||||
console.log(`${pathRelative(projectDirPath, keycloakDirInPublicDir)} directory created.`);
|
||||
})();
|
@ -2,47 +2,93 @@
|
||||
import { join as pathJoin } from "path";
|
||||
import { downloadAndUnzip } from "./tools/downloadAndUnzip";
|
||||
import { promptKeycloakVersion } from "./promptKeycloakVersion";
|
||||
import { getCliOptions } from "./tools/cliOptions";
|
||||
import { getLogger } from "./tools/logger";
|
||||
import { readBuildOptions } from "./keycloakify/BuildOptions";
|
||||
import * as child_process from "child_process";
|
||||
import * as fs from "fs";
|
||||
|
||||
export async function downloadBuiltinKeycloakTheme(params: { keycloakVersion: string; destDirPath: string; isSilent: boolean }) {
|
||||
const { keycloakVersion, destDirPath } = params;
|
||||
export async function downloadBuiltinKeycloakTheme(params: { projectDirPath: string; keycloakVersion: string; destDirPath: string }) {
|
||||
const { projectDirPath, keycloakVersion, destDirPath } = params;
|
||||
|
||||
await Promise.all(
|
||||
["", "-community"].map(ext =>
|
||||
downloadAndUnzip({
|
||||
"destDirPath": destDirPath,
|
||||
"url": `https://github.com/keycloak/keycloak/archive/refs/tags/${keycloakVersion}.zip`,
|
||||
"pathOfDirToExtractInArchive": `keycloak-${keycloakVersion}/themes/src/main/resources${ext}/theme`
|
||||
})
|
||||
)
|
||||
);
|
||||
await downloadAndUnzip({
|
||||
"doUseCache": true,
|
||||
projectDirPath,
|
||||
destDirPath,
|
||||
"url": `https://github.com/keycloak/keycloak/archive/refs/tags/${keycloakVersion}.zip`,
|
||||
"specificDirsToExtract": ["", "-community"].map(ext => `keycloak-${keycloakVersion}/themes/src/main/resources${ext}/theme`),
|
||||
"preCacheTransform": {
|
||||
"actionCacheId": "npm install and build",
|
||||
"action": async ({ destDirPath }) => {
|
||||
install_common_node_modules: {
|
||||
const commonResourcesDirPath = pathJoin(destDirPath, "keycloak", "common", "resources");
|
||||
|
||||
if (!fs.existsSync(commonResourcesDirPath)) {
|
||||
break install_common_node_modules;
|
||||
}
|
||||
|
||||
if (!fs.existsSync(pathJoin(commonResourcesDirPath, "package.json"))) {
|
||||
break install_common_node_modules;
|
||||
}
|
||||
|
||||
if (fs.existsSync(pathJoin(commonResourcesDirPath, "node_modules"))) {
|
||||
break install_common_node_modules;
|
||||
}
|
||||
|
||||
child_process.execSync("npm install --omit=dev", {
|
||||
"cwd": commonResourcesDirPath,
|
||||
"stdio": "ignore"
|
||||
});
|
||||
}
|
||||
|
||||
install_and_move_to_common_resources_generated_in_keycloak_v2: {
|
||||
const accountV2DirSrcDirPath = pathJoin(destDirPath, "keycloak.v2", "account", "src");
|
||||
|
||||
if (!fs.existsSync(accountV2DirSrcDirPath)) {
|
||||
break install_and_move_to_common_resources_generated_in_keycloak_v2;
|
||||
}
|
||||
|
||||
child_process.execSync("npm install", { "cwd": accountV2DirSrcDirPath, "stdio": "ignore" });
|
||||
|
||||
const packageJsonFilePath = pathJoin(accountV2DirSrcDirPath, "package.json");
|
||||
|
||||
const packageJsonRaw = fs.readFileSync(packageJsonFilePath);
|
||||
|
||||
const parsedPackageJson = JSON.parse(packageJsonRaw.toString("utf8"));
|
||||
|
||||
parsedPackageJson.scripts.build = parsedPackageJson.scripts.build
|
||||
.replace("npm run check-types", "true")
|
||||
.replace("npm run babel", "true");
|
||||
|
||||
fs.writeFileSync(packageJsonFilePath, Buffer.from(JSON.stringify(parsedPackageJson, null, 2), "utf8"));
|
||||
|
||||
child_process.execSync("npm run build", { "cwd": accountV2DirSrcDirPath, "stdio": "ignore" });
|
||||
|
||||
fs.writeFileSync(packageJsonFilePath, packageJsonRaw);
|
||||
|
||||
fs.rmSync(pathJoin(accountV2DirSrcDirPath, "node_modules"), { "recursive": true });
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { isSilent } = getCliOptions(process.argv.slice(2));
|
||||
const logger = getLogger({ isSilent });
|
||||
const buildOptions = readBuildOptions({
|
||||
"projectDirPath": process.cwd(),
|
||||
"processArgv": process.argv.slice(2)
|
||||
});
|
||||
|
||||
const logger = getLogger({ "isSilent": buildOptions.isSilent });
|
||||
const { keycloakVersion } = await promptKeycloakVersion();
|
||||
|
||||
const destDirPath = pathJoin(
|
||||
readBuildOptions({
|
||||
"isSilent": true,
|
||||
"isExternalAssetsCliParamProvided": false,
|
||||
"projectDirPath": process.cwd()
|
||||
}).keycloakifyBuildDirPath,
|
||||
"src",
|
||||
"main",
|
||||
"resources",
|
||||
"theme"
|
||||
);
|
||||
const destDirPath = pathJoin(buildOptions.keycloakifyBuildDirPath, "src", "main", "resources", "theme");
|
||||
|
||||
logger.log(`Downloading builtins theme of Keycloak ${keycloakVersion} here ${destDirPath}`);
|
||||
|
||||
await downloadBuiltinKeycloakTheme({
|
||||
"projectDirPath": process.cwd(),
|
||||
keycloakVersion,
|
||||
destDirPath,
|
||||
isSilent
|
||||
destDirPath
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -51,10 +51,6 @@ import { getThemeSrcDirPath } from "./getSrcDirPath";
|
||||
|
||||
const { themeSrcDirPath } = getThemeSrcDirPath({ "projectDirPath": process.cwd() });
|
||||
|
||||
if (themeSrcDirPath === undefined) {
|
||||
throw new Error("Couldn't locate your theme sources");
|
||||
}
|
||||
|
||||
const targetFilePath = pathJoin(themeSrcDirPath, themeType, "pages", pageBasename);
|
||||
|
||||
if (existsSync(targetFilePath)) {
|
||||
|
@ -2,42 +2,46 @@ import * as fs from "fs";
|
||||
import { exclude } from "tsafe";
|
||||
import { crawl } from "./tools/crawl";
|
||||
import { join as pathJoin } from "path";
|
||||
import { themeTypes } from "./keycloakify/generateFtl";
|
||||
|
||||
const themeSrcDirBasename = "keycloak-theme";
|
||||
const themeSrcDirBasenames = ["keycloak-theme", "keycloak_theme"];
|
||||
|
||||
/** Can't catch error, if the directory isn't found, this function will just exit the process with an error message. */
|
||||
export function getThemeSrcDirPath(params: { projectDirPath: string }) {
|
||||
const { projectDirPath } = params;
|
||||
|
||||
const srcDirPath = pathJoin(projectDirPath, "src");
|
||||
|
||||
const themeSrcDirPath: string | undefined = crawl(srcDirPath)
|
||||
const themeSrcDirPath: string | undefined = crawl({ "dirPath": srcDirPath, "returnedPathsType": "relative to dirPath" })
|
||||
.map(fileRelativePath => {
|
||||
const split = fileRelativePath.split(themeSrcDirBasename);
|
||||
|
||||
if (split.length !== 2) {
|
||||
return undefined;
|
||||
for (const themeSrcDirBasename of themeSrcDirBasenames) {
|
||||
const split = fileRelativePath.split(themeSrcDirBasename);
|
||||
if (split.length === 2) {
|
||||
return pathJoin(srcDirPath, split[0] + themeSrcDirBasename);
|
||||
}
|
||||
}
|
||||
|
||||
return pathJoin(srcDirPath, split[0] + themeSrcDirBasename);
|
||||
return undefined;
|
||||
})
|
||||
.filter(exclude(undefined))[0];
|
||||
|
||||
if (themeSrcDirPath === undefined) {
|
||||
if (fs.existsSync(pathJoin(srcDirPath, "login")) || fs.existsSync(pathJoin(srcDirPath, "account"))) {
|
||||
return { "themeSrcDirPath": srcDirPath };
|
||||
}
|
||||
return { "themeSrcDirPath": undefined };
|
||||
if (themeSrcDirPath !== undefined) {
|
||||
return { themeSrcDirPath };
|
||||
}
|
||||
|
||||
return { themeSrcDirPath };
|
||||
}
|
||||
|
||||
export function getEmailThemeSrcDirPath(params: { projectDirPath: string }) {
|
||||
const { projectDirPath } = params;
|
||||
|
||||
const { themeSrcDirPath } = getThemeSrcDirPath({ projectDirPath });
|
||||
|
||||
const emailThemeSrcDirPath = themeSrcDirPath === undefined ? undefined : pathJoin(themeSrcDirPath, "email");
|
||||
|
||||
return { emailThemeSrcDirPath };
|
||||
for (const themeType of [...themeTypes, "email"]) {
|
||||
if (!fs.existsSync(pathJoin(srcDirPath, themeType))) {
|
||||
continue;
|
||||
}
|
||||
return { "themeSrcDirPath": srcDirPath };
|
||||
}
|
||||
|
||||
console.error(
|
||||
[
|
||||
"Can't locate your theme source directory. It should be either: ",
|
||||
"src/ or src/keycloak-theme or src/keycloak_theme.",
|
||||
"Example in the starter: https://github.com/keycloakify/keycloakify-starter/tree/main/src/keycloak-theme"
|
||||
].join("\n")
|
||||
);
|
||||
|
||||
process.exit(-1);
|
||||
}
|
||||
|
@ -4,24 +4,26 @@ 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 { getEmailThemeSrcDirPath } from "./getSrcDirPath";
|
||||
import { getThemeSrcDirPath } from "./getSrcDirPath";
|
||||
|
||||
export async function main() {
|
||||
const { isSilent } = getCliOptions(process.argv.slice(2));
|
||||
const logger = getLogger({ isSilent });
|
||||
const projectDirPath = process.cwd();
|
||||
|
||||
const { emailThemeSrcDirPath } = getEmailThemeSrcDirPath({
|
||||
"projectDirPath": process.cwd()
|
||||
const { isSilent } = readBuildOptions({
|
||||
projectDirPath,
|
||||
"processArgv": process.argv.slice(2)
|
||||
});
|
||||
|
||||
if (emailThemeSrcDirPath === undefined) {
|
||||
logger.warn("Couldn't locate your theme source directory");
|
||||
const logger = getLogger({ isSilent });
|
||||
|
||||
process.exit(-1);
|
||||
}
|
||||
const { themeSrcDirPath } = getThemeSrcDirPath({
|
||||
projectDirPath
|
||||
});
|
||||
|
||||
const emailThemeSrcDirPath = pathJoin(themeSrcDirPath, "email");
|
||||
|
||||
if (fs.existsSync(emailThemeSrcDirPath)) {
|
||||
logger.warn(`There is already a ${pathRelative(process.cwd(), emailThemeSrcDirPath)} directory in your project. Aborting.`);
|
||||
@ -34,9 +36,9 @@ export async function main() {
|
||||
const builtinKeycloakThemeTmpDirPath = pathJoin(emailThemeSrcDirPath, "..", "tmp_xIdP3_builtin_keycloak_theme");
|
||||
|
||||
await downloadBuiltinKeycloakTheme({
|
||||
projectDirPath,
|
||||
keycloakVersion,
|
||||
"destDirPath": builtinKeycloakThemeTmpDirPath,
|
||||
isSilent
|
||||
"destDirPath": builtinKeycloakThemeTmpDirPath
|
||||
});
|
||||
|
||||
transformCodebase({
|
||||
|
@ -4,223 +4,135 @@ import { parse as urlParse } from "url";
|
||||
import { typeGuard } from "tsafe/typeGuard";
|
||||
import { symToStr } from "tsafe/symToStr";
|
||||
import { bundlers, getParsedPackageJson, type Bundler } from "./parsedPackageJson";
|
||||
import * as fs from "fs";
|
||||
import { join as pathJoin, sep as pathSep } from "path";
|
||||
import parseArgv from "minimist";
|
||||
|
||||
/** Consolidated build option gathered form CLI arguments and config in package.json */
|
||||
export type BuildOptions = BuildOptions.Standalone | BuildOptions.ExternalAssets;
|
||||
export type BuildOptions = {
|
||||
isSilent: boolean;
|
||||
themeVersion: string;
|
||||
themeName: string;
|
||||
extraThemeNames: string[];
|
||||
extraThemeProperties: string[] | undefined;
|
||||
groupId: string;
|
||||
artifactId: string;
|
||||
bundler: Bundler;
|
||||
keycloakVersionDefaultAssets: string;
|
||||
/** Directory of your built react project. Defaults to {cwd}/build */
|
||||
reactAppBuildDirPath: string;
|
||||
/** Directory that keycloakify outputs to. Defaults to {cwd}/build_keycloak */
|
||||
keycloakifyBuildDirPath: string;
|
||||
/** If your app is hosted under a subpath, it's the case in CRA if you have "homepage": "https://example.com/my-app" in your package.json
|
||||
* In this case the urlPathname will be "/my-app/" */
|
||||
urlPathname: string | undefined;
|
||||
};
|
||||
|
||||
export 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 function readBuildOptions(params: { projectDirPath: string; processArgv: string[] }): BuildOptions {
|
||||
const { projectDirPath, processArgv } = params;
|
||||
|
||||
export type Standalone = Common & {
|
||||
isStandalone: true;
|
||||
urlPathname: string | undefined;
|
||||
};
|
||||
const { isSilentCliParamProvided } = (() => {
|
||||
const argv = parseArgv(processArgv);
|
||||
|
||||
export type ExternalAssets = ExternalAssets.SameDomain | ExternalAssets.DifferentDomains;
|
||||
|
||||
export namespace ExternalAssets {
|
||||
export type CommonExternalAssets = Common & {
|
||||
isStandalone: false;
|
||||
return {
|
||||
"isSilentCliParamProvided": typeof argv["silent"] === "boolean" ? argv["silent"] : 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;
|
||||
const { name, keycloakify = {}, version, homepage } = parsedPackageJson;
|
||||
|
||||
let url: URL | undefined = undefined;
|
||||
const { extraThemeProperties, groupId, artifactId, bundler, keycloakVersionDefaultAssets, extraThemeNames = [] } = keycloakify ?? {};
|
||||
|
||||
if (homepage !== undefined) {
|
||||
url = new URL(homepage);
|
||||
}
|
||||
const themeName =
|
||||
keycloakify.themeName ??
|
||||
name
|
||||
.replace(/^@(.*)/, "$1")
|
||||
.split("/")
|
||||
.join("-");
|
||||
|
||||
const CNAME = (() => {
|
||||
const cnameFilePath = pathJoin(projectDirPath, "public", "CNAME");
|
||||
return {
|
||||
themeName,
|
||||
extraThemeNames,
|
||||
"bundler": (() => {
|
||||
const { KEYCLOAKIFY_BUNDLER } = process.env;
|
||||
|
||||
if (!fs.existsSync(cnameFilePath)) {
|
||||
assert(
|
||||
typeGuard<Bundler | undefined>(KEYCLOAKIFY_BUNDLER, [undefined, ...id<readonly string[]>(bundlers)].includes(KEYCLOAKIFY_BUNDLER)),
|
||||
`${symToStr({ KEYCLOAKIFY_BUNDLER })} should be one of ${bundlers.join(", ")}`
|
||||
);
|
||||
|
||||
return KEYCLOAKIFY_BUNDLER ?? bundler ?? "keycloakify";
|
||||
})(),
|
||||
"artifactId": process.env.KEYCLOAKIFY_ARTIFACT_ID ?? artifactId ?? `${themeName}-keycloak-theme`,
|
||||
"groupId": (() => {
|
||||
const fallbackGroupId = `${themeName}.keycloak`;
|
||||
|
||||
return (
|
||||
process.env.KEYCLOAKIFY_GROUP_ID ??
|
||||
groupId ??
|
||||
(!homepage
|
||||
? fallbackGroupId
|
||||
: urlParse(homepage)
|
||||
.host?.replace(/:[0-9]+$/, "")
|
||||
?.split(".")
|
||||
.reverse()
|
||||
.join(".") ?? fallbackGroupId) + ".keycloak"
|
||||
);
|
||||
})(),
|
||||
"themeVersion": process.env.KEYCLOAKIFY_THEME_VERSION ?? process.env.KEYCLOAKIFY_VERSION ?? version ?? "0.0.0",
|
||||
extraThemeProperties,
|
||||
"isSilent": isSilentCliParamProvided,
|
||||
"keycloakVersionDefaultAssets": keycloakVersionDefaultAssets ?? "11.0.3",
|
||||
"reactAppBuildDirPath": (() => {
|
||||
let { reactAppBuildDirPath = undefined } = parsedPackageJson.keycloakify ?? {};
|
||||
|
||||
if (reactAppBuildDirPath === undefined) {
|
||||
return pathJoin(projectDirPath, "build");
|
||||
}
|
||||
|
||||
if (pathSep === "\\") {
|
||||
reactAppBuildDirPath = reactAppBuildDirPath.replace(/\//g, pathSep);
|
||||
}
|
||||
|
||||
if (reactAppBuildDirPath.startsWith(`.${pathSep}`)) {
|
||||
return pathJoin(projectDirPath, reactAppBuildDirPath);
|
||||
}
|
||||
|
||||
return reactAppBuildDirPath;
|
||||
})(),
|
||||
"keycloakifyBuildDirPath": (() => {
|
||||
let { keycloakifyBuildDirPath = undefined } = parsedPackageJson.keycloakify ?? {};
|
||||
|
||||
if (keycloakifyBuildDirPath === undefined) {
|
||||
return pathJoin(projectDirPath, "build_keycloak");
|
||||
}
|
||||
|
||||
if (pathSep === "\\") {
|
||||
keycloakifyBuildDirPath = keycloakifyBuildDirPath.replace(/\//g, pathSep);
|
||||
}
|
||||
|
||||
if (keycloakifyBuildDirPath.startsWith(`.${pathSep}`)) {
|
||||
return pathJoin(projectDirPath, keycloakifyBuildDirPath);
|
||||
}
|
||||
|
||||
return keycloakifyBuildDirPath;
|
||||
})(),
|
||||
"urlPathname": (() => {
|
||||
const { homepage } = parsedPackageJson;
|
||||
|
||||
let url: URL | undefined = undefined;
|
||||
|
||||
if (homepage !== undefined) {
|
||||
url = new URL(homepage);
|
||||
}
|
||||
|
||||
if (url === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return fs.readFileSync(cnameFilePath).toString("utf8");
|
||||
})();
|
||||
|
||||
if (CNAME !== undefined) {
|
||||
url = new URL(`https://${CNAME.replace(/\s+$/, "")}`);
|
||||
}
|
||||
|
||||
if (url === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
"origin": url.origin,
|
||||
"pathname": (() => {
|
||||
const out = url.pathname.replace(/([^/])$/, "$1/");
|
||||
|
||||
return out === "/" ? undefined : out;
|
||||
})()
|
||||
};
|
||||
})();
|
||||
|
||||
const common: BuildOptions.Common = (() => {
|
||||
const { name, keycloakify = {}, version, homepage } = parsedPackageJson;
|
||||
|
||||
const { extraPages, extraLoginPages, extraAccountPages, extraThemeProperties, groupId, artifactId, bundler, keycloakVersionDefaultAssets } =
|
||||
keycloakify ?? {};
|
||||
|
||||
const themeName =
|
||||
keycloakify.themeName ??
|
||||
name
|
||||
.replace(/^@(.*)/, "$1")
|
||||
.split("/")
|
||||
.join("-");
|
||||
|
||||
return {
|
||||
themeName,
|
||||
"bundler": (() => {
|
||||
const { KEYCLOAKIFY_BUNDLER } = process.env;
|
||||
|
||||
assert(
|
||||
typeGuard<Bundler | undefined>(
|
||||
KEYCLOAKIFY_BUNDLER,
|
||||
[undefined, ...id<readonly string[]>(bundlers)].includes(KEYCLOAKIFY_BUNDLER)
|
||||
),
|
||||
`${symToStr({ KEYCLOAKIFY_BUNDLER })} should be one of ${bundlers.join(", ")}`
|
||||
);
|
||||
|
||||
return KEYCLOAKIFY_BUNDLER ?? bundler ?? "keycloakify";
|
||||
})(),
|
||||
"artifactId": process.env.KEYCLOAKIFY_ARTIFACT_ID ?? artifactId ?? `${themeName}-keycloak-theme`,
|
||||
"groupId": (() => {
|
||||
const fallbackGroupId = `${themeName}.keycloak`;
|
||||
|
||||
return (
|
||||
process.env.KEYCLOAKIFY_GROUP_ID ??
|
||||
groupId ??
|
||||
(!homepage
|
||||
? fallbackGroupId
|
||||
: urlParse(homepage)
|
||||
.host?.replace(/:[0-9]+$/, "")
|
||||
?.split(".")
|
||||
.reverse()
|
||||
.join(".") ?? fallbackGroupId) + ".keycloak"
|
||||
);
|
||||
})(),
|
||||
"themeVersion": process.env.KEYCLOAKIFY_THEME_VERSION ?? process.env.KEYCLOAKIFY_VERSION ?? version ?? "0.0.0",
|
||||
"extraLoginPages": [...(extraPages ?? []), ...(extraLoginPages ?? [])],
|
||||
extraAccountPages,
|
||||
extraThemeProperties,
|
||||
isSilent,
|
||||
"keycloakVersionDefaultAssets": keycloakVersionDefaultAssets ?? "11.0.3",
|
||||
"reactAppBuildDirPath": (() => {
|
||||
let { reactAppBuildDirPath = undefined } = parsedPackageJson.keycloakify ?? {};
|
||||
|
||||
if (reactAppBuildDirPath === undefined) {
|
||||
return pathJoin(projectDirPath, "build");
|
||||
}
|
||||
|
||||
if (pathSep === "\\") {
|
||||
reactAppBuildDirPath = reactAppBuildDirPath.replace(/\//g, pathSep);
|
||||
}
|
||||
|
||||
if (reactAppBuildDirPath.startsWith(`.${pathSep}`)) {
|
||||
return pathJoin(projectDirPath, reactAppBuildDirPath);
|
||||
}
|
||||
|
||||
return reactAppBuildDirPath;
|
||||
})(),
|
||||
"keycloakifyBuildDirPath": (() => {
|
||||
let { keycloakifyBuildDirPath = undefined } = parsedPackageJson.keycloakify ?? {};
|
||||
|
||||
if (keycloakifyBuildDirPath === undefined) {
|
||||
return pathJoin(projectDirPath, "build_keycloak");
|
||||
}
|
||||
|
||||
if (pathSep === "\\") {
|
||||
keycloakifyBuildDirPath = keycloakifyBuildDirPath.replace(/\//g, pathSep);
|
||||
}
|
||||
|
||||
if (keycloakifyBuildDirPath.startsWith(`.${pathSep}`)) {
|
||||
return pathJoin(projectDirPath, keycloakifyBuildDirPath);
|
||||
}
|
||||
|
||||
return keycloakifyBuildDirPath;
|
||||
})(),
|
||||
"customUserAttributes": keycloakify.customUserAttributes ?? []
|
||||
};
|
||||
})();
|
||||
|
||||
if (isExternalAssetsCliParamProvided) {
|
||||
const commonExternalAssets = id<BuildOptions.ExternalAssets.CommonExternalAssets>({
|
||||
...common,
|
||||
"isStandalone": false
|
||||
});
|
||||
|
||||
if (parsedPackageJson.keycloakify?.areAppAndKeycloakServerSharingSameDomain) {
|
||||
return id<BuildOptions.ExternalAssets.SameDomain>({
|
||||
...commonExternalAssets,
|
||||
"areAppAndKeycloakServerSharingSameDomain": true
|
||||
});
|
||||
} else {
|
||||
assert(
|
||||
url !== undefined,
|
||||
[
|
||||
"Can't compile in external assets mode if we don't know where",
|
||||
"the app will be hosted.",
|
||||
"You should provide a homepage field in the package.json (or create a",
|
||||
"public/CNAME file.",
|
||||
"Alternatively, if your app and the Keycloak server are on the same domain, ",
|
||||
"eg https://example.com is your app and https://example.com/auth is the keycloak",
|
||||
'admin UI, you can set "keycloakify": { "areAppAndKeycloakServerSharingSameDomain": true }',
|
||||
"in your package.json"
|
||||
].join(" ")
|
||||
);
|
||||
|
||||
return id<BuildOptions.ExternalAssets.DifferentDomains>({
|
||||
...commonExternalAssets,
|
||||
"areAppAndKeycloakServerSharingSameDomain": false,
|
||||
"urlOrigin": url.origin,
|
||||
"urlPathname": url.pathname
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return id<BuildOptions.Standalone>({
|
||||
...common,
|
||||
"isStandalone": true,
|
||||
"urlPathname": url?.pathname
|
||||
});
|
||||
const out = url.pathname.replace(/([^/])$/, "$1/");
|
||||
return out === "/" ? undefined : out;
|
||||
})()
|
||||
};
|
||||
}
|
||||
|
@ -8,13 +8,7 @@
|
||||
out["advancedMsg"]= function(){ throw new Error("use import { useKcMessage } from 'keycloakify'"); };
|
||||
|
||||
out["messagesPerField"]= {
|
||||
<#assign fieldNames = [
|
||||
"global", "userLabel", "username", "email", "firstName", "lastName", "password", "password-confirm",
|
||||
"totp", "totpSecret", "SAMLRequest", "SAMLResponse", "relayState", "device_user_code", "code",
|
||||
"password-new", "rememberMe", "login", "authenticationExecution", "cancel-aia", "clientDataJSON",
|
||||
"authenticatorData", "signature", "credentialId", "userHandle", "error", "authn_use_chk", "authenticationExecution",
|
||||
"isSetRetry", "try-again", "attestationObject", "publicKeyCredentialId", "authenticatorLabel"CUSTOM_USER_ATTRIBUTES_eKsIY4ZsZ4xeM
|
||||
]>
|
||||
<#assign fieldNames = [ FIELD_NAMES_eKsIY4ZsZ4xeM ]>
|
||||
|
||||
<#attempt>
|
||||
<#if profile?? && profile.attributes?? && profile.attributes?is_enumerable>
|
||||
@ -28,85 +22,374 @@
|
||||
<#recover>
|
||||
</#attempt>
|
||||
|
||||
"printIfExists": function (fieldName, x) {
|
||||
<#if !messagesPerField?? >
|
||||
return undefined;
|
||||
"printIfExists": function (fieldName, text) {
|
||||
|
||||
<#if !messagesPerField?? || !(messagesPerField?is_hash)>
|
||||
throw new Error("You're not supposed to use messagesPerField.printIfExists in this page");
|
||||
<#else>
|
||||
<#list fieldNames as fieldName>
|
||||
if(fieldName === "${fieldName}" ){
|
||||
<#attempt>
|
||||
<#if '${fieldName}' == 'username' || '${fieldName}' == 'password'>
|
||||
return <#if messagesPerField.existsError('username', 'password')>x<#else>undefined</#if>;
|
||||
|
||||
<#-- https://github.com/keycloakify/keycloakify/pull/359 Compat with Keycloak prior v12 -->
|
||||
<#if !messagesPerField.existsError??>
|
||||
|
||||
<#-- https://github.com/keycloakify/keycloakify/pull/218 -->
|
||||
<#if ('${fieldName}' == 'username' || '${fieldName}' == 'password') && pageId != 'register.ftl' && pageId != 'register-user-profile.ftl'>
|
||||
|
||||
<#assign doExistMessageForUsernameOrPassword = "">
|
||||
|
||||
<#attempt>
|
||||
<#assign doExistMessageForUsernameOrPassword = messagesPerField.exists('username')>
|
||||
<#recover>
|
||||
<#assign doExistMessageForUsernameOrPassword = true>
|
||||
</#attempt>
|
||||
|
||||
<#if !doExistMessageForUsernameOrPassword>
|
||||
<#attempt>
|
||||
<#assign doExistMessageForUsernameOrPassword = messagesPerField.exists('password')>
|
||||
<#recover>
|
||||
<#assign doExistMessageForUsernameOrPassword = true>
|
||||
</#attempt>
|
||||
</#if>
|
||||
|
||||
return <#if doExistMessageForUsernameOrPassword>text<#else>undefined</#if>;
|
||||
|
||||
<#else>
|
||||
return <#if messagesPerField.existsError('${fieldName}')>x<#else>undefined</#if>;
|
||||
|
||||
<#assign doExistMessageForField = "">
|
||||
|
||||
<#attempt>
|
||||
<#assign doExistMessageForField = messagesPerField.exists('${fieldName}')>
|
||||
<#recover>
|
||||
<#assign doExistMessageForField = true>
|
||||
</#attempt>
|
||||
|
||||
return <#if doExistMessageForField>text<#else>undefined</#if>;
|
||||
|
||||
</#if>
|
||||
<#recover>
|
||||
</#attempt>
|
||||
|
||||
<#else>
|
||||
|
||||
<#-- https://github.com/keycloakify/keycloakify/pull/218 -->
|
||||
<#if ('${fieldName}' == 'username' || '${fieldName}' == 'password') && pageId != 'register.ftl' && pageId != 'register-user-profile.ftl'>
|
||||
|
||||
<#assign doExistErrorOnUsernameOrPassword = "">
|
||||
|
||||
<#attempt>
|
||||
<#assign doExistErrorOnUsernameOrPassword = messagesPerField.existsError('username', 'password')>
|
||||
<#recover>
|
||||
<#assign doExistErrorOnUsernameOrPassword = true>
|
||||
</#attempt>
|
||||
|
||||
<#if doExistErrorOnUsernameOrPassword>
|
||||
return text;
|
||||
<#else>
|
||||
|
||||
<#assign doExistMessageForField = "">
|
||||
|
||||
<#attempt>
|
||||
<#assign doExistMessageForField = messagesPerField.exists('${fieldName}')>
|
||||
<#recover>
|
||||
<#assign doExistMessageForField = true>
|
||||
</#attempt>
|
||||
|
||||
return <#if doExistMessageForField>text<#else>undefined</#if>;
|
||||
|
||||
</#if>
|
||||
|
||||
<#else>
|
||||
|
||||
<#assign doExistMessageForField = "">
|
||||
|
||||
<#attempt>
|
||||
<#assign doExistMessageForField = messagesPerField.exists('${fieldName}')>
|
||||
<#recover>
|
||||
<#assign doExistMessageForField = true>
|
||||
</#attempt>
|
||||
|
||||
return <#if doExistMessageForField>text<#else>undefined</#if>;
|
||||
|
||||
</#if>
|
||||
|
||||
</#if>
|
||||
|
||||
}
|
||||
</#list>
|
||||
throw new Error("There is no " + fieldName + " field");
|
||||
|
||||
throw new Error(fieldName + "is probably runtime generated, see: https://docs.keycloakify.dev/limitations#field-names-cant-be-runtime-generated");
|
||||
</#if>
|
||||
|
||||
},
|
||||
"existsError": function (fieldName) {
|
||||
<#if !messagesPerField?? >
|
||||
return false;
|
||||
|
||||
<#if !messagesPerField?? || !(messagesPerField?is_hash)>
|
||||
throw new Error("You're not supposed to use messagesPerField.printIfExists in this page");
|
||||
<#else>
|
||||
<#list fieldNames as fieldName>
|
||||
if(fieldName === "${fieldName}" ){
|
||||
<#attempt>
|
||||
<#if '${fieldName}' == 'username' || '${fieldName}' == 'password'>
|
||||
return <#if messagesPerField.existsError('username', 'password')>true<#else>false</#if>;
|
||||
|
||||
<#-- https://github.com/keycloakify/keycloakify/pull/359 Compat with Keycloak prior v12 -->
|
||||
<#if !messagesPerField.existsError??>
|
||||
|
||||
<#-- https://github.com/keycloakify/keycloakify/pull/218 -->
|
||||
<#if ('${fieldName}' == 'username' || '${fieldName}' == 'password') && pageId != 'register.ftl' && pageId != 'register-user-profile.ftl'>
|
||||
|
||||
<#assign doExistMessageForUsernameOrPassword = "">
|
||||
|
||||
<#attempt>
|
||||
<#assign doExistMessageForUsernameOrPassword = messagesPerField.exists('username')>
|
||||
<#recover>
|
||||
<#assign doExistMessageForUsernameOrPassword = true>
|
||||
</#attempt>
|
||||
|
||||
<#if !doExistMessageForUsernameOrPassword>
|
||||
<#attempt>
|
||||
<#assign doExistMessageForUsernameOrPassword = messagesPerField.exists('password')>
|
||||
<#recover>
|
||||
<#assign doExistMessageForUsernameOrPassword = true>
|
||||
</#attempt>
|
||||
</#if>
|
||||
|
||||
return <#if doExistMessageForUsernameOrPassword>true<#else>false</#if>;
|
||||
|
||||
<#else>
|
||||
return <#if messagesPerField.existsError('${fieldName}')>true<#else>false</#if>;
|
||||
|
||||
<#assign doExistMessageForField = "">
|
||||
|
||||
<#attempt>
|
||||
<#assign doExistMessageForField = messagesPerField.exists('${fieldName}')>
|
||||
<#recover>
|
||||
<#assign doExistMessageForField = true>
|
||||
</#attempt>
|
||||
|
||||
return <#if doExistMessageForField>true<#else>false</#if>;
|
||||
|
||||
</#if>
|
||||
<#recover>
|
||||
</#attempt>
|
||||
|
||||
<#else>
|
||||
|
||||
<#-- https://github.com/keycloakify/keycloakify/pull/218 -->
|
||||
<#if ('${fieldName}' == 'username' || '${fieldName}' == 'password') && pageId != 'register.ftl' && pageId != 'register-user-profile.ftl'>
|
||||
|
||||
<#assign doExistErrorOnUsernameOrPassword = "">
|
||||
|
||||
<#attempt>
|
||||
<#assign doExistErrorOnUsernameOrPassword = messagesPerField.existsError('username', 'password')>
|
||||
<#recover>
|
||||
<#assign doExistErrorOnUsernameOrPassword = true>
|
||||
</#attempt>
|
||||
|
||||
return <#if doExistErrorOnUsernameOrPassword>true<#else>false</#if>;
|
||||
|
||||
<#else>
|
||||
|
||||
<#assign doExistErrorMessageForField = "">
|
||||
|
||||
<#attempt>
|
||||
<#assign doExistErrorMessageForField = messagesPerField.existsError('${fieldName}')>
|
||||
<#recover>
|
||||
<#assign doExistErrorMessageForField = true>
|
||||
</#attempt>
|
||||
|
||||
return <#if doExistErrorMessageForField>true<#else>false</#if>;
|
||||
|
||||
</#if>
|
||||
|
||||
</#if>
|
||||
|
||||
}
|
||||
</#list>
|
||||
throw new Error("There is no " + fieldName + " field");
|
||||
|
||||
throw new Error(fieldName + "is probably runtime generated, see: https://docs.keycloakify.dev/limitations#field-names-cant-be-runtime-generated");
|
||||
|
||||
</#if>
|
||||
|
||||
},
|
||||
"get": function (fieldName) {
|
||||
<#if !messagesPerField?? >
|
||||
return '';
|
||||
|
||||
|
||||
<#if !messagesPerField?? || !(messagesPerField?is_hash)>
|
||||
throw new Error("You're not supposed to use messagesPerField.get in this page");
|
||||
<#else>
|
||||
<#list fieldNames as fieldName>
|
||||
if(fieldName === "${fieldName}" ){
|
||||
<#attempt>
|
||||
<#if '${fieldName}' == 'username' || '${fieldName}' == 'password'>
|
||||
<#if messagesPerField.existsError('username', 'password')>
|
||||
return 'Invalid username or password.';
|
||||
|
||||
<#-- https://github.com/keycloakify/keycloakify/pull/359 Compat with Keycloak prior v12 -->
|
||||
<#if !messagesPerField.existsError??>
|
||||
|
||||
<#-- https://github.com/keycloakify/keycloakify/pull/218 -->
|
||||
<#if ('${fieldName}' == 'username' || '${fieldName}' == 'password') && pageId != 'register.ftl' && pageId != 'register-user-profile.ftl'>
|
||||
|
||||
<#assign doExistMessageForUsernameOrPassword = "">
|
||||
|
||||
<#attempt>
|
||||
<#assign doExistMessageForUsernameOrPassword = messagesPerField.exists('username')>
|
||||
<#recover>
|
||||
<#assign doExistMessageForUsernameOrPassword = true>
|
||||
</#attempt>
|
||||
|
||||
<#if !doExistMessageForUsernameOrPassword>
|
||||
<#attempt>
|
||||
<#assign doExistMessageForUsernameOrPassword = messagesPerField.exists('password')>
|
||||
<#recover>
|
||||
<#assign doExistMessageForUsernameOrPassword = true>
|
||||
</#attempt>
|
||||
</#if>
|
||||
|
||||
<#if !doExistMessageForUsernameOrPassword>
|
||||
return "";
|
||||
<#else>
|
||||
<#attempt>
|
||||
return "${kcSanitize(msg('invalidUserMessage'))?no_esc}";
|
||||
<#recover>
|
||||
return "Invalid username or password.";
|
||||
</#attempt>
|
||||
</#if>
|
||||
|
||||
<#else>
|
||||
<#if messagesPerField.existsError('${fieldName}')>
|
||||
|
||||
<#attempt>
|
||||
return "${messagesPerField.get('${fieldName}')?no_esc}";
|
||||
</#if>
|
||||
<#recover>
|
||||
return "invalid field";
|
||||
</#attempt>
|
||||
|
||||
</#if>
|
||||
<#recover>
|
||||
</#attempt>
|
||||
|
||||
<#else>
|
||||
|
||||
<#-- https://github.com/keycloakify/keycloakify/pull/218 -->
|
||||
<#if ('${fieldName}' == 'username' || '${fieldName}' == 'password') && pageId != 'register.ftl' && pageId != 'register-user-profile.ftl'>
|
||||
|
||||
<#assign doExistErrorOnUsernameOrPassword = "">
|
||||
|
||||
<#attempt>
|
||||
<#assign doExistErrorOnUsernameOrPassword = messagesPerField.existsError('username', 'password')>
|
||||
<#recover>
|
||||
<#assign doExistErrorOnUsernameOrPassword = true>
|
||||
</#attempt>
|
||||
|
||||
<#if doExistErrorOnUsernameOrPassword>
|
||||
|
||||
<#attempt>
|
||||
return "${kcSanitize(msg('invalidUserMessage'))?no_esc}";
|
||||
<#recover>
|
||||
return "Invalid username or password.";
|
||||
</#attempt>
|
||||
|
||||
<#else>
|
||||
|
||||
<#attempt>
|
||||
return "${messagesPerField.get('${fieldName}')?no_esc}";
|
||||
<#recover>
|
||||
return "";
|
||||
</#attempt>
|
||||
|
||||
</#if>
|
||||
|
||||
<#else>
|
||||
|
||||
<#attempt>
|
||||
return "${messagesPerField.get('${fieldName}')?no_esc}";
|
||||
<#recover>
|
||||
return "invalid field";
|
||||
</#attempt>
|
||||
|
||||
</#if>
|
||||
|
||||
</#if>
|
||||
|
||||
}
|
||||
</#list>
|
||||
throw new Error("There is no " + fieldName + " field");
|
||||
|
||||
throw new Error(fieldName + "is probably runtime generated, see: https://docs.keycloakify.dev/limitations#field-names-cant-be-runtime-generated");
|
||||
|
||||
</#if>
|
||||
|
||||
},
|
||||
"exists": function (fieldName) {
|
||||
<#if !messagesPerField?? >
|
||||
return false;
|
||||
|
||||
<#if !messagesPerField?? || !(messagesPerField?is_hash)>
|
||||
throw new Error("You're not supposed to use messagesPerField.exists in this page");
|
||||
<#else>
|
||||
<#list fieldNames as fieldName>
|
||||
if(fieldName === "${fieldName}" ){
|
||||
<#attempt>
|
||||
<#if '${fieldName}' == 'username' || '${fieldName}' == 'password'>
|
||||
return <#if messagesPerField.exists('username') || messagesPerField.exists('password')>true<#else>false</#if>;
|
||||
|
||||
<#-- https://github.com/keycloakify/keycloakify/pull/359 Compat with Keycloak prior v12 -->
|
||||
<#if !messagesPerField.existsError??>
|
||||
|
||||
<#-- https://github.com/keycloakify/keycloakify/pull/218 -->
|
||||
<#if ('${fieldName}' == 'username' || '${fieldName}' == 'password') && pageId != 'register.ftl' && pageId != 'register-user-profile.ftl'>
|
||||
|
||||
<#assign doExistMessageForUsernameOrPassword = "">
|
||||
|
||||
<#attempt>
|
||||
<#assign doExistMessageForUsernameOrPassword = messagesPerField.exists('username')>
|
||||
<#recover>
|
||||
<#assign doExistMessageForUsernameOrPassword = true>
|
||||
</#attempt>
|
||||
|
||||
<#if !doExistMessageForUsernameOrPassword>
|
||||
<#attempt>
|
||||
<#assign doExistMessageForUsernameOrPassword = messagesPerField.exists('password')>
|
||||
<#recover>
|
||||
<#assign doExistMessageForUsernameOrPassword = true>
|
||||
</#attempt>
|
||||
</#if>
|
||||
|
||||
return <#if doExistMessageForUsernameOrPassword>true<#else>false</#if>;
|
||||
|
||||
<#else>
|
||||
return <#if messagesPerField.exists('${fieldName}')>true<#else>false</#if>;
|
||||
|
||||
<#assign doExistMessageForField = "">
|
||||
|
||||
<#attempt>
|
||||
<#assign doExistMessageForField = messagesPerField.exists('${fieldName}')>
|
||||
<#recover>
|
||||
<#assign doExistMessageForField = true>
|
||||
</#attempt>
|
||||
|
||||
return <#if doExistMessageForField>true<#else>false</#if>;
|
||||
|
||||
</#if>
|
||||
<#recover>
|
||||
</#attempt>
|
||||
|
||||
<#else>
|
||||
|
||||
<#-- https://github.com/keycloakify/keycloakify/pull/218 -->
|
||||
<#if ('${fieldName}' == 'username' || '${fieldName}' == 'password') && pageId != 'register.ftl' && pageId != 'register-user-profile.ftl'>
|
||||
|
||||
<#assign doExistErrorOnUsernameOrPassword = "">
|
||||
|
||||
<#attempt>
|
||||
<#assign doExistErrorOnUsernameOrPassword = messagesPerField.existsError('username', 'password')>
|
||||
<#recover>
|
||||
<#assign doExistErrorOnUsernameOrPassword = true>
|
||||
</#attempt>
|
||||
|
||||
return <#if doExistErrorOnUsernameOrPassword>true<#else>false</#if>;
|
||||
|
||||
<#else>
|
||||
|
||||
<#assign doExistErrorMessageForField = "">
|
||||
|
||||
<#attempt>
|
||||
<#assign doExistErrorMessageForField = messagesPerField.exists('${fieldName}')>
|
||||
<#recover>
|
||||
<#assign doExistErrorMessageForField = true>
|
||||
</#attempt>
|
||||
|
||||
return <#if doExistErrorMessageForField>true<#else>false</#if>;
|
||||
|
||||
</#if>
|
||||
|
||||
</#if>
|
||||
|
||||
}
|
||||
</#list>
|
||||
throw new Error("There is no " + fieldName + " field");
|
||||
|
||||
throw new Error(fieldName + "is probably runtime generated, see: https://docs.keycloakify.dev/limitations#field-names-cant-be-runtime-generated");
|
||||
</#if>
|
||||
|
||||
}
|
||||
};
|
||||
|
||||
@ -121,6 +404,8 @@
|
||||
|
||||
out["keycloakifyVersion"] = "KEYCLOAKIFY_VERSION_xEdKd3xEdr";
|
||||
out["themeVersion"] = "KEYCLOAKIFY_THEME_VERSION_sIgKd3xEdr3dx";
|
||||
out["themeType"] = "KEYCLOAKIFY_THEME_TYPE_dExKd3xEdr";
|
||||
out["themeName"] = "KEYCLOAKIFY_THEME_NAME_cXxKd3xEer";
|
||||
out["pageId"] = "${pageId}";
|
||||
|
||||
return out;
|
||||
@ -169,10 +454,15 @@
|
||||
<#-- 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 -->
|
||||
key == "loginAction" &&
|
||||
are_same_path(path, ["url"]) &&
|
||||
["saml-post-form.ftl", "error.ftl", "info.ftl"]?seq_contains(pageId) &&
|
||||
["saml-post-form.ftl", "error.ftl", "info.ftl", "login-oauth-grant.ftl", "logout-confirm.ftl"]?seq_contains(pageId) &&
|
||||
!(auth?has_content && auth.showTryAnotherWayLink())
|
||||
) || (
|
||||
<#-- https://github.com/keycloakify/keycloakify/issues/362 -->
|
||||
["secretData", "value"]?seq_contains(key) &&
|
||||
are_same_path(path, [ "totp", "otpCredentials", "*" ])
|
||||
) || (
|
||||
["contextData", "idpConfig", "idp", "authenticationSession"]?seq_contains(key) &&
|
||||
are_same_path(path, ["brokerContext"]) &&
|
||||
@ -188,22 +478,27 @@
|
||||
"error.ftl" == pageId &&
|
||||
are_same_path(path, ["realm"]) &&
|
||||
!["name", "displayName", "displayNameHtml", "internationalizationEnabled", "registrationEmailAsUsername" ]?seq_contains(key)
|
||||
) || (
|
||||
"applications.ftl" == pageId &&
|
||||
are_same_path(path, ["applications", "applications", "*", "client", "realm"])
|
||||
) || (
|
||||
"applications.ftl" == pageId &&
|
||||
"masterAdminClient" == key
|
||||
)
|
||||
>
|
||||
<#local out_seq += ["/*If you need '" + key + "' on " + pageId + ", please submit an issue to the Keycloakify repo*/"]>
|
||||
<#continue>
|
||||
</#if>
|
||||
|
||||
<#if key == "attemptedUsername" && are_same_path(path, ["auth"])>
|
||||
|
||||
<#if pageId == "register.ftl" && 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 -->
|
||||
<#-- https://github.com/keycloakify/keycloakify/discussions/406 -->
|
||||
<#if !(auth?has_content && auth.showUsername() && !auth.showResetCredentials())>
|
||||
<#continue>
|
||||
</#if>
|
||||
<#recover>
|
||||
</#attempt>
|
||||
|
||||
</#if>
|
||||
|
||||
<#attempt>
|
||||
@ -336,6 +631,17 @@
|
||||
|
||||
</#if>
|
||||
|
||||
<#local isDate = "">
|
||||
<#attempt>
|
||||
<#local isDate = object?is_date_like>
|
||||
<#recover>
|
||||
<#return "ABORT: Can't test if it's a date">
|
||||
</#attempt>
|
||||
|
||||
<#if isDate>
|
||||
<#return '"' + object?datetime?iso_utc + '"'>
|
||||
</#if>
|
||||
|
||||
<#attempt>
|
||||
<#return '"' + object?js_string + '"'>;
|
||||
<#recover>
|
||||
|
@ -13,39 +13,11 @@ export const themeTypes = ["login", "account"] as const;
|
||||
|
||||
export type ThemeType = (typeof themeTypes)[number];
|
||||
|
||||
export type BuildOptionsLike = BuildOptionsLike.Standalone | BuildOptionsLike.ExternalAssets;
|
||||
|
||||
export namespace BuildOptionsLike {
|
||||
export type Common = {
|
||||
customUserAttributes: string[];
|
||||
themeVersion: string;
|
||||
};
|
||||
|
||||
export type Standalone = Common & {
|
||||
isStandalone: true;
|
||||
urlPathname: string | undefined;
|
||||
};
|
||||
|
||||
export type ExternalAssets = ExternalAssets.SameDomain | ExternalAssets.DifferentDomains;
|
||||
|
||||
export namespace ExternalAssets {
|
||||
export type CommonExternalAssets = {
|
||||
isStandalone: false;
|
||||
};
|
||||
|
||||
export type SameDomain = Common &
|
||||
CommonExternalAssets & {
|
||||
areAppAndKeycloakServerSharingSameDomain: true;
|
||||
};
|
||||
|
||||
export type DifferentDomains = Common &
|
||||
CommonExternalAssets & {
|
||||
areAppAndKeycloakServerSharingSameDomain: false;
|
||||
urlOrigin: string;
|
||||
urlPathname: string | undefined;
|
||||
};
|
||||
}
|
||||
}
|
||||
export type BuildOptionsLike = {
|
||||
themeName: string;
|
||||
themeVersion: string;
|
||||
urlPathname: string | undefined;
|
||||
};
|
||||
|
||||
assert<BuildOptions extends BuildOptionsLike ? true : false>();
|
||||
|
||||
@ -55,28 +27,31 @@ export function generateFtlFilesCodeFactory(params: {
|
||||
cssGlobalsToDefine: Record<string, string>;
|
||||
buildOptions: BuildOptionsLike;
|
||||
keycloakifyVersion: string;
|
||||
themeType: ThemeType;
|
||||
fieldNames: string[];
|
||||
}) {
|
||||
const { cssGlobalsToDefine, indexHtmlCode, buildOptions, keycloakifyVersion } = params;
|
||||
const { cssGlobalsToDefine, indexHtmlCode, buildOptions, keycloakifyVersion, themeType, fieldNames } = params;
|
||||
|
||||
const $ = cheerio.load(indexHtmlCode);
|
||||
|
||||
fix_imports_statements: {
|
||||
if (!buildOptions.isStandalone && buildOptions.areAppAndKeycloakServerSharingSameDomain) {
|
||||
break fix_imports_statements;
|
||||
}
|
||||
|
||||
$("script:not([src])").each((...[, element]) => {
|
||||
const { fixedJsCode } = replaceImportsFromStaticInJsCode({
|
||||
"jsCode": $(element).html()!,
|
||||
buildOptions
|
||||
});
|
||||
const jsCode = $(element).html();
|
||||
|
||||
assert(jsCode !== null);
|
||||
|
||||
const { fixedJsCode } = replaceImportsFromStaticInJsCode({ jsCode });
|
||||
|
||||
$(element).text(fixedJsCode);
|
||||
});
|
||||
|
||||
$("style").each((...[, element]) => {
|
||||
const cssCode = $(element).html();
|
||||
|
||||
assert(cssCode !== null);
|
||||
|
||||
const { fixedCssCode } = replaceImportsInInlineCssCode({
|
||||
"cssCode": $(element).html()!,
|
||||
cssCode,
|
||||
buildOptions
|
||||
});
|
||||
|
||||
@ -98,9 +73,7 @@ export function generateFtlFilesCodeFactory(params: {
|
||||
|
||||
$(element).attr(
|
||||
attrName,
|
||||
buildOptions.isStandalone
|
||||
? href.replace(new RegExp(`^${(buildOptions.urlPathname ?? "/").replace(/\//g, "\\/")}`), "${url.resourcesPath}/build/")
|
||||
: href.replace(/^\//, `${buildOptions.urlOrigin}/`)
|
||||
href.replace(new RegExp(`^${(buildOptions.urlPathname ?? "/").replace(/\//g, "\\/")}`), "${url.resourcesPath}/build/")
|
||||
);
|
||||
})
|
||||
);
|
||||
@ -127,12 +100,11 @@ export function generateFtlFilesCodeFactory(params: {
|
||||
.readFileSync(pathJoin(__dirname, "ftl_object_to_js_code_declaring_an_object.ftl"))
|
||||
.toString("utf8")
|
||||
.match(/^<script>const _=((?:.|\n)+)<\/script>[\n]?$/)![1]
|
||||
.replace(
|
||||
"CUSTOM_USER_ATTRIBUTES_eKsIY4ZsZ4xeM",
|
||||
buildOptions.customUserAttributes.length === 0 ? "" : ", " + buildOptions.customUserAttributes.map(name => `"${name}"`).join(", ")
|
||||
)
|
||||
.replace("FIELD_NAMES_eKsIY4ZsZ4xeM", fieldNames.map(name => `"${name}"`).join(", "))
|
||||
.replace("KEYCLOAKIFY_VERSION_xEdKd3xEdr", keycloakifyVersion)
|
||||
.replace("KEYCLOAKIFY_THEME_VERSION_sIgKd3xEdr3dx", buildOptions.themeVersion),
|
||||
.replace("KEYCLOAKIFY_THEME_VERSION_sIgKd3xEdr3dx", buildOptions.themeVersion)
|
||||
.replace("KEYCLOAKIFY_THEME_TYPE_dExKd3xEdr", themeType)
|
||||
.replace("KEYCLOAKIFY_THEME_NAME_cXxKd3xEer", buildOptions.themeName),
|
||||
"<!-- xIdLqMeOedErIdLsPdNdI9dSlxI -->": [
|
||||
"<#if scripts??>",
|
||||
" <#list scripts as script>",
|
||||
|
@ -21,7 +21,8 @@ export const loginThemePageIds = [
|
||||
"update-user-profile.ftl",
|
||||
"idp-review-user-profile.ftl",
|
||||
"update-email.ftl",
|
||||
"select-authenticator.ftl"
|
||||
"select-authenticator.ftl",
|
||||
"saml-post-form.ftl"
|
||||
] as const;
|
||||
|
||||
export const accountThemePageIds = ["password.ftl", "account.ftl"] as const;
|
||||
|
@ -1,34 +1,30 @@
|
||||
import * as fs from "fs";
|
||||
import { join as pathJoin, dirname as pathDirname } from "path";
|
||||
import { themeTypes } from "./generateFtl/generateFtl";
|
||||
import { assert } from "tsafe/assert";
|
||||
import { Reflect } from "tsafe/Reflect";
|
||||
import type { BuildOptions } from "./BuildOptions";
|
||||
import type { ThemeType } from "./generateFtl";
|
||||
|
||||
export type BuildOptionsLike = {
|
||||
themeName: string;
|
||||
extraThemeNames: string[];
|
||||
groupId: string;
|
||||
artifactId?: string;
|
||||
artifactId: string;
|
||||
themeVersion: string;
|
||||
};
|
||||
|
||||
{
|
||||
const buildOptions = Reflect<BuildOptions>();
|
||||
|
||||
assert<typeof buildOptions extends BuildOptionsLike ? true : false>();
|
||||
}
|
||||
assert<BuildOptions extends BuildOptionsLike ? true : false>();
|
||||
|
||||
export function generateJavaStackFiles(params: {
|
||||
keycloakThemeBuildingDirPath: string;
|
||||
doBundlesEmailTemplate: boolean;
|
||||
implementedThemeTypes: Record<ThemeType | "email", boolean>;
|
||||
buildOptions: BuildOptionsLike;
|
||||
}): {
|
||||
jarFilePath: string;
|
||||
} {
|
||||
const {
|
||||
buildOptions: { groupId, themeName, themeVersion, artifactId },
|
||||
buildOptions: { groupId, themeName, extraThemeNames, themeVersion, artifactId },
|
||||
keycloakThemeBuildingDirPath,
|
||||
doBundlesEmailTemplate
|
||||
implementedThemeTypes
|
||||
} = params;
|
||||
|
||||
{
|
||||
@ -67,12 +63,12 @@ export function generateJavaStackFiles(params: {
|
||||
Buffer.from(
|
||||
JSON.stringify(
|
||||
{
|
||||
"themes": [
|
||||
{
|
||||
"name": themeName,
|
||||
"types": [...themeTypes, ...(doBundlesEmailTemplate ? ["email"] : [])]
|
||||
}
|
||||
]
|
||||
"themes": [themeName, ...extraThemeNames].map(themeName => ({
|
||||
"name": themeName,
|
||||
"types": Object.entries(implementedThemeTypes)
|
||||
.filter(([, isImplemented]) => isImplemented)
|
||||
.map(([themeType]) => themeType)
|
||||
}))
|
||||
},
|
||||
null,
|
||||
2
|
||||
|
@ -1,241 +0,0 @@
|
||||
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 };
|
||||
}
|
@ -1,18 +1,14 @@
|
||||
import * as fs from "fs";
|
||||
import { join as pathJoin } from "path";
|
||||
import { assert } from "tsafe/assert";
|
||||
import { Reflect } from "tsafe/Reflect";
|
||||
import type { BuildOptions } from "./BuildOptions";
|
||||
|
||||
export type BuildOptionsLike = {
|
||||
themeName: string;
|
||||
extraThemeNames: string[];
|
||||
};
|
||||
|
||||
{
|
||||
const buildOptions = Reflect<BuildOptions>();
|
||||
|
||||
assert<typeof buildOptions extends BuildOptionsLike ? true : false>();
|
||||
}
|
||||
assert<BuildOptions extends BuildOptionsLike ? true : false>();
|
||||
|
||||
generateStartKeycloakTestingContainer.basename = "start_keycloak_testing_container.sh";
|
||||
|
||||
@ -27,14 +23,11 @@ export function generateStartKeycloakTestingContainer(params: {
|
||||
const {
|
||||
keycloakThemeBuildingDirPath,
|
||||
keycloakVersion,
|
||||
buildOptions: { themeName }
|
||||
buildOptions: { themeName, extraThemeNames }
|
||||
} = params;
|
||||
|
||||
const keycloakThemePath = pathJoin(keycloakThemeBuildingDirPath, "src", "main", "resources", "theme", themeName).replace(/\\/g, "/");
|
||||
|
||||
fs.writeFileSync(
|
||||
pathJoin(keycloakThemeBuildingDirPath, generateStartKeycloakTestingContainer.basename),
|
||||
|
||||
Buffer.from(
|
||||
[
|
||||
"#!/usr/bin/env bash",
|
||||
@ -48,10 +41,15 @@ export function generateStartKeycloakTestingContainer(params: {
|
||||
` --name ${containerName} \\`,
|
||||
" -e KEYCLOAK_ADMIN=admin \\",
|
||||
" -e KEYCLOAK_ADMIN_PASSWORD=admin \\",
|
||||
" -e JAVA_OPTS=-Dkeycloak.profile=preview \\",
|
||||
` -v "${keycloakThemePath}":"/opt/keycloak/themes/${themeName}":rw \\`,
|
||||
...[themeName, ...extraThemeNames].map(
|
||||
themeName =>
|
||||
` -v "${pathJoin(keycloakThemeBuildingDirPath, "src", "main", "resources", "theme", themeName).replace(
|
||||
/\\/g,
|
||||
"/"
|
||||
)}":"/opt/keycloak/themes/${themeName}":rw \\`
|
||||
),
|
||||
` -it quay.io/keycloak/keycloak:${keycloakVersion} \\`,
|
||||
` start-dev`,
|
||||
` start-dev --features=declarative-user-profile`,
|
||||
""
|
||||
].join("\n"),
|
||||
"utf8"
|
||||
|
@ -0,0 +1,86 @@
|
||||
import { transformCodebase } from "../../tools/transformCodebase";
|
||||
import * as fs from "fs";
|
||||
import { join as pathJoin, relative as pathRelative, dirname as pathDirname } from "path";
|
||||
import type { ThemeType } from "../generateFtl";
|
||||
import { downloadBuiltinKeycloakTheme } from "../../download-builtin-keycloak-theme";
|
||||
import {
|
||||
resourcesCommonDirPathRelativeToPublicDir,
|
||||
resourcesDirPathRelativeToPublicDir,
|
||||
basenameOfKeycloakDirInPublicDir
|
||||
} from "../../mockTestingResourcesPath";
|
||||
import * as crypto from "crypto";
|
||||
import { assert } from "tsafe/assert";
|
||||
|
||||
export async function downloadKeycloakStaticResources(
|
||||
// prettier-ignore
|
||||
params: {
|
||||
projectDirPath: string;
|
||||
themeType: ThemeType;
|
||||
themeDirPath: string;
|
||||
keycloakVersion: string;
|
||||
usedResources: {
|
||||
resourcesCommonFilePaths: string[];
|
||||
} | undefined
|
||||
}
|
||||
) {
|
||||
const { projectDirPath, themeType, themeDirPath, keycloakVersion } = params;
|
||||
|
||||
// NOTE: Hack for 427
|
||||
const usedResources = (() => {
|
||||
const { usedResources } = params;
|
||||
|
||||
if (usedResources === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
assert(usedResources !== undefined);
|
||||
|
||||
return {
|
||||
"resourcesCommonDirPaths": usedResources.resourcesCommonFilePaths.map(filePath => {
|
||||
{
|
||||
const splitArg = "/dist/";
|
||||
|
||||
if (filePath.includes(splitArg)) {
|
||||
return filePath.split(splitArg)[0] + splitArg;
|
||||
}
|
||||
}
|
||||
|
||||
return pathDirname(filePath);
|
||||
})
|
||||
};
|
||||
})();
|
||||
|
||||
const tmpDirPath = pathJoin(
|
||||
themeDirPath,
|
||||
"..",
|
||||
`tmp_suLeKsxId_${crypto.createHash("sha256").update(`${themeType}-${keycloakVersion}`).digest("hex").slice(0, 8)}`
|
||||
);
|
||||
|
||||
await downloadBuiltinKeycloakTheme({
|
||||
projectDirPath,
|
||||
keycloakVersion,
|
||||
"destDirPath": tmpDirPath
|
||||
});
|
||||
|
||||
transformCodebase({
|
||||
"srcDirPath": pathJoin(tmpDirPath, "keycloak", themeType, "resources"),
|
||||
"destDirPath": pathJoin(themeDirPath, pathRelative(basenameOfKeycloakDirInPublicDir, resourcesDirPathRelativeToPublicDir))
|
||||
});
|
||||
|
||||
transformCodebase({
|
||||
"srcDirPath": pathJoin(tmpDirPath, "keycloak", "common", "resources"),
|
||||
"destDirPath": pathJoin(themeDirPath, pathRelative(basenameOfKeycloakDirInPublicDir, resourcesCommonDirPathRelativeToPublicDir)),
|
||||
"transformSourceCode":
|
||||
usedResources === undefined
|
||||
? undefined
|
||||
: ({ fileRelativePath, sourceCode }) => {
|
||||
if (usedResources.resourcesCommonDirPaths.find(dirPath => fileRelativePath.startsWith(dirPath)) === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return { "modifiedSourceCode": sourceCode };
|
||||
}
|
||||
});
|
||||
|
||||
fs.rmSync(tmpDirPath, { "recursive": true, "force": true });
|
||||
}
|
179
src/bin/keycloakify/generateTheme/generateMessageProperties.ts
Normal file
179
src/bin/keycloakify/generateTheme/generateMessageProperties.ts
Normal file
@ -0,0 +1,179 @@
|
||||
import type { ThemeType } from "../generateFtl";
|
||||
import { crawl } from "../../tools/crawl";
|
||||
import { join as pathJoin } from "path";
|
||||
import { readFileSync } from "fs";
|
||||
import { symToStr } from "tsafe/symToStr";
|
||||
import { removeDuplicates } from "evt/tools/reducers/removeDuplicates";
|
||||
import * as recast from "recast";
|
||||
import * as babelParser from "@babel/parser";
|
||||
import babelGenerate from "@babel/generator";
|
||||
import * as babelTypes from "@babel/types";
|
||||
|
||||
export function generateMessageProperties(params: {
|
||||
themeSrcDirPath: string;
|
||||
themeType: ThemeType;
|
||||
}): { languageTag: string; propertiesFileSource: string }[] {
|
||||
const { themeSrcDirPath, themeType } = params;
|
||||
|
||||
let files = crawl({
|
||||
"dirPath": pathJoin(themeSrcDirPath, themeType),
|
||||
"returnedPathsType": "absolute"
|
||||
});
|
||||
|
||||
files = files.filter(file => {
|
||||
const regex = /\.(js|ts|tsx)$/;
|
||||
return regex.test(file);
|
||||
});
|
||||
|
||||
files = files.sort((a, b) => {
|
||||
const regex = /\.i18n\.(ts|js|tsx)$/;
|
||||
const aIsI18nFile = regex.test(a);
|
||||
const bIsI18nFile = regex.test(b);
|
||||
return aIsI18nFile === bIsI18nFile ? 0 : aIsI18nFile ? -1 : 1;
|
||||
});
|
||||
|
||||
files = files.sort((a, b) => a.length - b.length);
|
||||
|
||||
files = files.filter(file => readFileSync(file).toString("utf8").includes("createUseI18n"));
|
||||
|
||||
if (files.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const extraMessages = files
|
||||
.map(file => {
|
||||
const root = recast.parse(readFileSync(file).toString("utf8"), {
|
||||
"parser": {
|
||||
"parse": (code: string) => babelParser.parse(code, { "sourceType": "module", "plugins": ["typescript"] }),
|
||||
"generator": babelGenerate,
|
||||
"types": babelTypes
|
||||
}
|
||||
});
|
||||
|
||||
const codes: string[] = [];
|
||||
|
||||
recast.visit(root, {
|
||||
"visitCallExpression": function (path) {
|
||||
if (path.node.callee.type === "Identifier" && path.node.callee.name === "createUseI18n") {
|
||||
codes.push(babelGenerate(path.node.arguments[0] as any).code);
|
||||
}
|
||||
this.traverse(path);
|
||||
}
|
||||
});
|
||||
|
||||
return codes;
|
||||
})
|
||||
.flat()
|
||||
.map(code => {
|
||||
let extraMessages: { [languageTag: string]: Record<string, string> } = {};
|
||||
|
||||
try {
|
||||
eval(`${symToStr({ extraMessages })} = ${code}`);
|
||||
} catch {
|
||||
console.warn(
|
||||
[
|
||||
"WARNING: Make sure that the first argument of createUseI18n can be evaluated in a javascript",
|
||||
"runtime where only the node globals are available.",
|
||||
"This is important because we need to put your i18n messages in messages_*.properties files",
|
||||
"or they won't be available server side.",
|
||||
"\n",
|
||||
"The following code could not be evaluated:",
|
||||
"\n",
|
||||
code
|
||||
].join(" ")
|
||||
);
|
||||
}
|
||||
|
||||
return extraMessages;
|
||||
});
|
||||
|
||||
const languageTags = extraMessages
|
||||
.map(extraMessage => Object.keys(extraMessage))
|
||||
.flat()
|
||||
.reduce(...removeDuplicates<string>());
|
||||
|
||||
const keyValueMapByLanguageTag: Record<string, Record<string, string>> = {};
|
||||
|
||||
for (const languageTag of languageTags) {
|
||||
const keyValueMap: Record<string, string> = {};
|
||||
|
||||
for (const extraMessage of extraMessages) {
|
||||
const keyValueMap_i = extraMessage[languageTag];
|
||||
|
||||
if (keyValueMap_i === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(keyValueMap_i)) {
|
||||
if (keyValueMap[key] !== undefined) {
|
||||
console.warn(
|
||||
[
|
||||
"WARNING: The following key is defined multiple times:",
|
||||
"\n",
|
||||
key,
|
||||
"\n",
|
||||
"The following value will be ignored:",
|
||||
"\n",
|
||||
value,
|
||||
"\n",
|
||||
"The following value was already defined:",
|
||||
"\n",
|
||||
keyValueMap[key]
|
||||
].join(" ")
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
keyValueMap[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
keyValueMapByLanguageTag[languageTag] = keyValueMap;
|
||||
}
|
||||
|
||||
const out: { languageTag: string; propertiesFileSource: string }[] = [];
|
||||
|
||||
for (const [languageTag, keyValueMap] of Object.entries(keyValueMapByLanguageTag)) {
|
||||
const propertiesFileSource = Object.entries(keyValueMap)
|
||||
.map(([key, value]) => `${key}=${escapeString(value)}`)
|
||||
.join("\n");
|
||||
|
||||
out.push({
|
||||
languageTag,
|
||||
"propertiesFileSource": ["# This file was generated by keycloakify", "", "parent=base", "", propertiesFileSource, ""].join("\n")
|
||||
});
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
// Convert a JavaScript string to UTF-16 encoding
|
||||
function toUTF16(codePoint: number): string {
|
||||
if (codePoint <= 0xffff) {
|
||||
// BMP character
|
||||
return "\\u" + codePoint.toString(16).padStart(4, "0");
|
||||
} else {
|
||||
// Non-BMP character
|
||||
codePoint -= 0x10000;
|
||||
let highSurrogate = (codePoint >> 10) + 0xd800;
|
||||
let lowSurrogate = (codePoint % 0x400) + 0xdc00;
|
||||
return "\\u" + highSurrogate.toString(16).padStart(4, "0") + "\\u" + lowSurrogate.toString(16).padStart(4, "0");
|
||||
}
|
||||
}
|
||||
|
||||
// Escapes special characters and converts unicode to UTF-16 encoding
|
||||
function escapeString(str: string): string {
|
||||
let escapedStr = "";
|
||||
for (const char of [...str]) {
|
||||
const codePoint = char.codePointAt(0);
|
||||
if (!codePoint) continue;
|
||||
if (char === "'") {
|
||||
escapedStr += "''"; // double single quotes
|
||||
} else if (codePoint > 0x7f) {
|
||||
escapedStr += toUTF16(codePoint); // non-ascii characters
|
||||
} else {
|
||||
escapedStr += char;
|
||||
}
|
||||
}
|
||||
return escapedStr;
|
||||
}
|
228
src/bin/keycloakify/generateTheme/generateTheme.ts
Normal file
228
src/bin/keycloakify/generateTheme/generateTheme.ts
Normal file
@ -0,0 +1,228 @@
|
||||
import { transformCodebase } from "../../tools/transformCodebase";
|
||||
import * as fs from "fs";
|
||||
import { join as pathJoin } from "path";
|
||||
import { replaceImportsFromStaticInJsCode } from "../replacers/replaceImportsFromStaticInJsCode";
|
||||
import { replaceImportsInCssCode } from "../replacers/replaceImportsInCssCode";
|
||||
import { generateFtlFilesCodeFactory, loginThemePageIds, accountThemePageIds, themeTypes, type ThemeType } from "../generateFtl";
|
||||
import { basenameOfKeycloakDirInPublicDir } from "../../mockTestingResourcesPath";
|
||||
import { isInside } from "../../tools/isInside";
|
||||
import type { BuildOptions } from "../BuildOptions";
|
||||
import { assert } from "tsafe/assert";
|
||||
import { downloadKeycloakStaticResources } from "./downloadKeycloakStaticResources";
|
||||
import { readFieldNameUsage } from "./readFieldNameUsage";
|
||||
import { readExtraPagesNames } from "./readExtraPageNames";
|
||||
import { generateMessageProperties } from "./generateMessageProperties";
|
||||
import { readStaticResourcesUsage } from "./readStaticResourcesUsage";
|
||||
|
||||
export type BuildOptionsLike = {
|
||||
themeName: string;
|
||||
extraThemeProperties: string[] | undefined;
|
||||
themeVersion: string;
|
||||
keycloakVersionDefaultAssets: string;
|
||||
urlPathname: string | undefined;
|
||||
};
|
||||
|
||||
assert<BuildOptions extends BuildOptionsLike ? true : false>();
|
||||
|
||||
export async function generateTheme(params: {
|
||||
projectDirPath: string;
|
||||
reactAppBuildDirPath: string;
|
||||
keycloakThemeBuildingDirPath: string;
|
||||
themeSrcDirPath: string;
|
||||
keycloakifySrcDirPath: string;
|
||||
buildOptions: BuildOptionsLike;
|
||||
keycloakifyVersion: string;
|
||||
}): Promise<void> {
|
||||
const {
|
||||
projectDirPath,
|
||||
reactAppBuildDirPath,
|
||||
keycloakThemeBuildingDirPath,
|
||||
themeSrcDirPath,
|
||||
keycloakifySrcDirPath,
|
||||
buildOptions,
|
||||
keycloakifyVersion
|
||||
} = params;
|
||||
|
||||
const getThemeDirPath = (themeType: ThemeType | "email") =>
|
||||
pathJoin(keycloakThemeBuildingDirPath, "src", "main", "resources", "theme", buildOptions.themeName, themeType);
|
||||
|
||||
let allCssGlobalsToDefine: Record<string, string> = {};
|
||||
|
||||
let generateFtlFilesCode_glob: ReturnType<typeof generateFtlFilesCodeFactory>["generateFtlFilesCode"] | undefined = undefined;
|
||||
|
||||
for (const themeType of themeTypes) {
|
||||
if (!fs.existsSync(pathJoin(themeSrcDirPath, themeType))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const themeDirPath = getThemeDirPath(themeType);
|
||||
|
||||
copy_app_resources_to_theme_path: {
|
||||
const isFirstPass = themeType.indexOf(themeType) === 0;
|
||||
|
||||
if (!isFirstPass) {
|
||||
break copy_app_resources_to_theme_path;
|
||||
}
|
||||
|
||||
transformCodebase({
|
||||
"destDirPath": pathJoin(themeDirPath, "resources", "build"),
|
||||
"srcDirPath": reactAppBuildDirPath,
|
||||
"transformSourceCode": ({ filePath, sourceCode }) => {
|
||||
//NOTE: Prevent cycles, excludes the folder we generated for debug in public/
|
||||
if (
|
||||
isInside({
|
||||
"dirPath": pathJoin(reactAppBuildDirPath, basenameOfKeycloakDirInPublicDir),
|
||||
filePath
|
||||
})
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (/\.css?$/i.test(filePath)) {
|
||||
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)) {
|
||||
const { fixedJsCode } = replaceImportsFromStaticInJsCode({
|
||||
"jsCode": sourceCode.toString("utf8")
|
||||
});
|
||||
|
||||
return { "modifiedSourceCode": Buffer.from(fixedJsCode, "utf8") };
|
||||
}
|
||||
|
||||
return { "modifiedSourceCode": sourceCode };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const generateFtlFilesCode =
|
||||
generateFtlFilesCode_glob !== undefined
|
||||
? generateFtlFilesCode_glob
|
||||
: generateFtlFilesCodeFactory({
|
||||
"indexHtmlCode": fs.readFileSync(pathJoin(reactAppBuildDirPath, "index.html")).toString("utf8"),
|
||||
"cssGlobalsToDefine": allCssGlobalsToDefine,
|
||||
buildOptions,
|
||||
keycloakifyVersion,
|
||||
themeType,
|
||||
"fieldNames": readFieldNameUsage({
|
||||
keycloakifySrcDirPath,
|
||||
themeSrcDirPath,
|
||||
themeType
|
||||
})
|
||||
}).generateFtlFilesCode;
|
||||
|
||||
[
|
||||
...(() => {
|
||||
switch (themeType) {
|
||||
case "login":
|
||||
return loginThemePageIds;
|
||||
case "account":
|
||||
return accountThemePageIds;
|
||||
}
|
||||
})(),
|
||||
...readExtraPagesNames({
|
||||
themeType,
|
||||
themeSrcDirPath
|
||||
})
|
||||
].forEach(pageId => {
|
||||
const { ftlCode } = generateFtlFilesCode({ pageId });
|
||||
|
||||
fs.mkdirSync(themeDirPath, { "recursive": true });
|
||||
|
||||
fs.writeFileSync(pathJoin(themeDirPath, pageId), Buffer.from(ftlCode, "utf8"));
|
||||
});
|
||||
|
||||
generateMessageProperties({
|
||||
themeSrcDirPath,
|
||||
themeType
|
||||
}).forEach(({ languageTag, propertiesFileSource }) => {
|
||||
const messagesDirPath = pathJoin(themeDirPath, "messages");
|
||||
|
||||
fs.mkdirSync(pathJoin(themeDirPath, "messages"), { "recursive": true });
|
||||
|
||||
const propertiesFilePath = pathJoin(messagesDirPath, `messages_${languageTag}.properties`);
|
||||
|
||||
fs.writeFileSync(propertiesFilePath, Buffer.from(propertiesFileSource, "utf8"));
|
||||
});
|
||||
|
||||
//TODO: Remove this block we left it for now only for backward compatibility
|
||||
// we now have a separate script for this
|
||||
copy_keycloak_resources_to_public: {
|
||||
const keycloakDirInPublicDir = pathJoin(reactAppBuildDirPath, "..", "public", basenameOfKeycloakDirInPublicDir);
|
||||
|
||||
if (fs.existsSync(keycloakDirInPublicDir)) {
|
||||
break copy_keycloak_resources_to_public;
|
||||
}
|
||||
|
||||
await downloadKeycloakStaticResources({
|
||||
projectDirPath,
|
||||
"keycloakVersion": buildOptions.keycloakVersionDefaultAssets,
|
||||
"themeDirPath": keycloakDirInPublicDir,
|
||||
themeType,
|
||||
"usedResources": undefined
|
||||
});
|
||||
|
||||
if (themeType !== themeTypes[0]) {
|
||||
break copy_keycloak_resources_to_public;
|
||||
}
|
||||
|
||||
fs.writeFileSync(
|
||||
pathJoin(keycloakDirInPublicDir, "README.txt"),
|
||||
Buffer.from(
|
||||
// prettier-ignore
|
||||
[
|
||||
"This is just a test folder that helps develop",
|
||||
"the login and register page without having to run a Keycloak container"
|
||||
].join(" ")
|
||||
)
|
||||
);
|
||||
|
||||
fs.writeFileSync(pathJoin(keycloakDirInPublicDir, ".gitignore"), Buffer.from("*", "utf8"));
|
||||
}
|
||||
|
||||
await downloadKeycloakStaticResources({
|
||||
projectDirPath,
|
||||
"keycloakVersion": buildOptions.keycloakVersionDefaultAssets,
|
||||
themeDirPath,
|
||||
themeType,
|
||||
"usedResources": readStaticResourcesUsage({
|
||||
keycloakifySrcDirPath,
|
||||
themeSrcDirPath,
|
||||
themeType
|
||||
})
|
||||
});
|
||||
|
||||
fs.writeFileSync(
|
||||
pathJoin(themeDirPath, "theme.properties"),
|
||||
Buffer.from([`parent=keycloak`, ...(buildOptions.extraThemeProperties ?? [])].join("\n\n"), "utf8")
|
||||
);
|
||||
}
|
||||
|
||||
email: {
|
||||
const emailThemeSrcDirPath = pathJoin(themeSrcDirPath, "email");
|
||||
|
||||
if (!fs.existsSync(emailThemeSrcDirPath)) {
|
||||
break email;
|
||||
}
|
||||
|
||||
transformCodebase({
|
||||
"srcDirPath": emailThemeSrcDirPath,
|
||||
"destDirPath": getThemeDirPath("email")
|
||||
});
|
||||
}
|
||||
}
|
1
src/bin/keycloakify/generateTheme/index.ts
Normal file
1
src/bin/keycloakify/generateTheme/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./generateTheme";
|
38
src/bin/keycloakify/generateTheme/readExtraPageNames.ts
Normal file
38
src/bin/keycloakify/generateTheme/readExtraPageNames.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import { crawl } from "../../tools/crawl";
|
||||
import { type ThemeType, accountThemePageIds, loginThemePageIds } from "../generateFtl";
|
||||
import { id } from "tsafe/id";
|
||||
import { removeDuplicates } from "evt/tools/reducers/removeDuplicates";
|
||||
import * as fs from "fs";
|
||||
import { join as pathJoin } from "path";
|
||||
|
||||
export function readExtraPagesNames(params: { themeSrcDirPath: string; themeType: ThemeType }): string[] {
|
||||
const { themeSrcDirPath, themeType } = params;
|
||||
|
||||
const filePaths = crawl({
|
||||
"dirPath": pathJoin(themeSrcDirPath, themeType),
|
||||
"returnedPathsType": "absolute"
|
||||
}).filter(filePath => /\.(ts|tsx|js|jsx)$/.test(filePath));
|
||||
|
||||
const candidateFilePaths = filePaths.filter(filePath => /kcContext\.[^.]+$/.test(filePath));
|
||||
|
||||
if (candidateFilePaths.length === 0) {
|
||||
candidateFilePaths.push(...filePaths);
|
||||
}
|
||||
|
||||
const extraPages: string[] = [];
|
||||
|
||||
for (const candidateFilPath of candidateFilePaths) {
|
||||
const rawSourceFile = fs.readFileSync(candidateFilPath).toString("utf8");
|
||||
|
||||
extraPages.push(...Array.from(rawSourceFile.matchAll(/["']?pageId["']?\s*:\s*["']([^.]+.ftl)["']/g), m => m[1]));
|
||||
}
|
||||
|
||||
return extraPages.reduce(...removeDuplicates<string>()).filter(pageId => {
|
||||
switch (themeType) {
|
||||
case "account":
|
||||
return !id<readonly string[]>(accountThemePageIds).includes(pageId);
|
||||
case "login":
|
||||
return !id<readonly string[]>(loginThemePageIds).includes(pageId);
|
||||
}
|
||||
});
|
||||
}
|
32
src/bin/keycloakify/generateTheme/readFieldNameUsage.ts
Normal file
32
src/bin/keycloakify/generateTheme/readFieldNameUsage.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import { crawl } from "../../tools/crawl";
|
||||
import { removeDuplicates } from "evt/tools/reducers/removeDuplicates";
|
||||
import { join as pathJoin } from "path";
|
||||
import * as fs from "fs";
|
||||
import type { ThemeType } from "../generateFtl";
|
||||
|
||||
/** Assumes the theme type exists */
|
||||
export function readFieldNameUsage(params: { keycloakifySrcDirPath: string; themeSrcDirPath: string; themeType: ThemeType }): string[] {
|
||||
const { keycloakifySrcDirPath, themeSrcDirPath, themeType } = params;
|
||||
|
||||
const fieldNames: string[] = [];
|
||||
|
||||
for (const srcDirPath of [pathJoin(keycloakifySrcDirPath, themeType), pathJoin(themeSrcDirPath, themeType)]) {
|
||||
const filePaths = crawl({ "dirPath": srcDirPath, "returnedPathsType": "absolute" }).filter(filePath => /\.(ts|tsx|js|jsx)$/.test(filePath));
|
||||
|
||||
for (const filePath of filePaths) {
|
||||
const rawSourceFile = fs.readFileSync(filePath).toString("utf8");
|
||||
|
||||
if (!rawSourceFile.includes("messagesPerField")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
fieldNames.push(
|
||||
...Array.from(rawSourceFile.matchAll(/(?:(?:printIfExists)|(?:existsError)|(?:get)|(?:exists))\(\s*["']([^"']+)["']/g), m => m[1])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const out = fieldNames.reduce(...removeDuplicates<string>());
|
||||
|
||||
return out;
|
||||
}
|
@ -0,0 +1,76 @@
|
||||
import { crawl } from "../../tools/crawl";
|
||||
import { join as pathJoin, sep as pathSep } from "path";
|
||||
import * as fs from "fs";
|
||||
import type { ThemeType } from "../generateFtl";
|
||||
|
||||
/** Assumes the theme type exists */
|
||||
export function readStaticResourcesUsage(params: { keycloakifySrcDirPath: string; themeSrcDirPath: string; themeType: ThemeType }): {
|
||||
resourcesCommonFilePaths: string[];
|
||||
} {
|
||||
const { keycloakifySrcDirPath, themeSrcDirPath, themeType } = params;
|
||||
|
||||
const resourcesCommonFilePaths = new Set<string>();
|
||||
|
||||
for (const srcDirPath of [pathJoin(keycloakifySrcDirPath, themeType), pathJoin(themeSrcDirPath, themeType)]) {
|
||||
const filePaths = crawl({ "dirPath": srcDirPath, "returnedPathsType": "absolute" }).filter(filePath => /\.(ts|tsx|js|jsx)$/.test(filePath));
|
||||
|
||||
for (const filePath of filePaths) {
|
||||
const rawSourceFile = fs.readFileSync(filePath).toString("utf8");
|
||||
|
||||
if (!rawSourceFile.includes("resourcesCommonPath") && !rawSourceFile.includes("resourcesPath")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const wrap = readPaths({ rawSourceFile });
|
||||
|
||||
wrap.resourcesCommonFilePaths.forEach(filePath => resourcesCommonFilePaths.add(filePath));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
"resourcesCommonFilePaths": Array.from(resourcesCommonFilePaths)
|
||||
};
|
||||
}
|
||||
|
||||
/** Exported for testing purpose */
|
||||
export function readPaths(params: { rawSourceFile: string }): {
|
||||
resourcesCommonFilePaths: string[];
|
||||
} {
|
||||
const { rawSourceFile } = params;
|
||||
|
||||
const resourcesCommonFilePaths = new Set<string>();
|
||||
|
||||
{
|
||||
const regexp = new RegExp(`resourcesCommonPath\\s*}([^\`]+)\``, "g");
|
||||
|
||||
const matches = [...rawSourceFile.matchAll(regexp)];
|
||||
|
||||
for (const match of matches) {
|
||||
const filePath = match[1];
|
||||
|
||||
resourcesCommonFilePaths.add(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
const regexp = new RegExp(`resourcesCommonPath\\s*[+,]\\s*["']([^"'\`]+)["'\`]`, "g");
|
||||
|
||||
const matches = [...rawSourceFile.matchAll(regexp)];
|
||||
|
||||
for (const match of matches) {
|
||||
const filePath = match[1];
|
||||
|
||||
resourcesCommonFilePaths.add(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
const normalizePath = (filePath: string) => {
|
||||
filePath = filePath.startsWith("/") ? filePath.slice(1) : filePath;
|
||||
filePath = filePath.replace(/\//g, pathSep);
|
||||
return filePath;
|
||||
};
|
||||
|
||||
return {
|
||||
"resourcesCommonFilePaths": Array.from(resourcesCommonFilePaths).map(normalizePath)
|
||||
};
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
import { generateKeycloakThemeResources } from "./generateKeycloakThemeResources";
|
||||
import { generateTheme } from "./generateTheme";
|
||||
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";
|
||||
@ -6,52 +6,67 @@ import { generateStartKeycloakTestingContainer } from "./generateStartKeycloakTe
|
||||
import * as fs from "fs";
|
||||
import { readBuildOptions } from "./BuildOptions";
|
||||
import { getLogger } from "../tools/logger";
|
||||
import { getCliOptions } from "../tools/cliOptions";
|
||||
import jar from "../tools/jar";
|
||||
import { assert } from "tsafe/assert";
|
||||
import { Equals } from "tsafe";
|
||||
import { getEmailThemeSrcDirPath } from "../getSrcDirPath";
|
||||
import { getThemeSrcDirPath } from "../getSrcDirPath";
|
||||
import { getProjectRoot } from "../tools/getProjectRoot";
|
||||
import { objectKeys } from "tsafe/objectKeys";
|
||||
|
||||
export async function main() {
|
||||
const { isSilent, hasExternalAssets } = getCliOptions(process.argv.slice(2));
|
||||
const logger = getLogger({ isSilent });
|
||||
logger.log("🔏 Building the keycloak theme...⌚");
|
||||
|
||||
const projectDirPath = process.cwd();
|
||||
|
||||
const buildOptions = readBuildOptions({
|
||||
projectDirPath,
|
||||
"isExternalAssetsCliParamProvided": hasExternalAssets,
|
||||
"isSilent": isSilent
|
||||
"processArgv": process.argv.slice(2)
|
||||
});
|
||||
|
||||
const { doBundlesEmailTemplate } = await generateKeycloakThemeResources({
|
||||
keycloakThemeBuildingDirPath: buildOptions.keycloakifyBuildDirPath,
|
||||
"emailThemeSrcDirPath": (() => {
|
||||
const { emailThemeSrcDirPath } = getEmailThemeSrcDirPath({ projectDirPath });
|
||||
const logger = getLogger({ "isSilent": buildOptions.isSilent });
|
||||
logger.log("🔏 Building the keycloak theme...⌚");
|
||||
|
||||
if (emailThemeSrcDirPath === undefined || !fs.existsSync(emailThemeSrcDirPath)) {
|
||||
return;
|
||||
}
|
||||
const keycloakifyDirPath = getProjectRoot();
|
||||
|
||||
return emailThemeSrcDirPath;
|
||||
})(),
|
||||
"reactAppBuildDirPath": buildOptions.reactAppBuildDirPath,
|
||||
buildOptions,
|
||||
"keycloakVersion": buildOptions.keycloakVersionDefaultAssets,
|
||||
"keycloakifyVersion": (() => {
|
||||
const version = JSON.parse(fs.readFileSync(pathJoin(getProjectRoot(), "package.json")).toString("utf8"))["version"];
|
||||
const { themeSrcDirPath } = getThemeSrcDirPath({ projectDirPath });
|
||||
|
||||
assert(typeof version === "string");
|
||||
for (const themeName of [buildOptions.themeName, ...buildOptions.extraThemeNames]) {
|
||||
await generateTheme({
|
||||
projectDirPath,
|
||||
"keycloakThemeBuildingDirPath": buildOptions.keycloakifyBuildDirPath,
|
||||
themeSrcDirPath,
|
||||
"keycloakifySrcDirPath": pathJoin(keycloakifyDirPath, "src"),
|
||||
"reactAppBuildDirPath": buildOptions.reactAppBuildDirPath,
|
||||
"buildOptions": {
|
||||
...buildOptions,
|
||||
"themeName": themeName
|
||||
},
|
||||
"keycloakifyVersion": (() => {
|
||||
const version = JSON.parse(fs.readFileSync(pathJoin(keycloakifyDirPath, "package.json")).toString("utf8"))["version"];
|
||||
|
||||
return version;
|
||||
})()
|
||||
});
|
||||
assert(typeof version === "string");
|
||||
|
||||
return version;
|
||||
})()
|
||||
});
|
||||
}
|
||||
|
||||
const { jarFilePath } = generateJavaStackFiles({
|
||||
keycloakThemeBuildingDirPath: buildOptions.keycloakifyBuildDirPath,
|
||||
doBundlesEmailTemplate,
|
||||
"keycloakThemeBuildingDirPath": buildOptions.keycloakifyBuildDirPath,
|
||||
"implementedThemeTypes": (() => {
|
||||
const implementedThemeTypes = {
|
||||
"login": false,
|
||||
"account": false,
|
||||
"email": false
|
||||
};
|
||||
|
||||
for (const themeType of objectKeys(implementedThemeTypes)) {
|
||||
if (!fs.existsSync(pathJoin(themeSrcDirPath, themeType))) {
|
||||
continue;
|
||||
}
|
||||
implementedThemeTypes[themeType] = true;
|
||||
}
|
||||
|
||||
return implementedThemeTypes;
|
||||
})(),
|
||||
buildOptions
|
||||
});
|
||||
|
||||
@ -62,7 +77,7 @@ export async function main() {
|
||||
case "keycloakify":
|
||||
logger.log("🫶 Let keycloakify do its thang");
|
||||
await jar({
|
||||
"rootPath": pathJoin(buildOptions.keycloakifyBuildDirPath, "src", "main", "resources"),
|
||||
"rootPath": buildOptions.keycloakifyBuildDirPath,
|
||||
"version": buildOptions.themeVersion,
|
||||
"groupId": buildOptions.groupId,
|
||||
"artifactId": buildOptions.artifactId,
|
||||
@ -78,7 +93,7 @@ export async function main() {
|
||||
}
|
||||
|
||||
// We want, however, to test in a container running the latest Keycloak version
|
||||
const containerKeycloakVersion = "20.0.1";
|
||||
const containerKeycloakVersion = "21.1.2";
|
||||
|
||||
generateStartKeycloakTestingContainer({
|
||||
keycloakThemeBuildingDirPath: buildOptions.keycloakifyBuildDirPath,
|
||||
@ -132,16 +147,18 @@ export async function main() {
|
||||
``,
|
||||
`Once your container is up and running: `,
|
||||
"- Log into the admin console 👉 http://localhost:8080/admin username: admin, password: admin 👈",
|
||||
`- Create a realm: myrealm`,
|
||||
`- Enable registration: Realm settings -> Login tab -> User registration: on`,
|
||||
`- Enable the Account theme: Realm settings -> Themes tab -> Account theme, select ${buildOptions.themeName} `,
|
||||
`- Create a client id myclient`,
|
||||
` Root URL: https://www.keycloak.org/app/`,
|
||||
` Valid redirect URIs: https://www.keycloak.org/app* http://localhost* (localhost is optional)`,
|
||||
` Valid post logout redirect URIs: https://www.keycloak.org/app* http://localhost*`,
|
||||
` Web origins: *`,
|
||||
` Login Theme: ${buildOptions.themeName}`,
|
||||
` Save (button at the bottom of the page)`,
|
||||
`- Create a realm: Master -> AddRealm -> Name: myrealm`,
|
||||
`- Enable registration: Realm settings -> Login tab -> User registration: on`,
|
||||
`- Enable the Account theme (optional): Realm settings -> Themes tab -> Account theme: ${buildOptions.themeName}`,
|
||||
` Clients -> account -> Login theme: ${buildOptions.themeName}`,
|
||||
`- Enable the email theme (optional): Realm settings -> Themes tab -> Email theme: ${buildOptions.themeName} (option will appear only if you have ran npx initialize-email-theme)`,
|
||||
`- Create a client Clients -> Create -> Client ID: myclient`,
|
||||
` Root URL: https://www.keycloak.org/app/`,
|
||||
` Valid redirect URIs: https://www.keycloak.org/app* http://localhost* (localhost is optional)`,
|
||||
` Valid post logout redirect URIs: https://www.keycloak.org/app* http://localhost*`,
|
||||
` Web origins: *`,
|
||||
` Login Theme: ${buildOptions.themeName}`,
|
||||
` Save (button at the bottom of the page)`,
|
||||
``,
|
||||
`- Go to 👉 https://www.keycloak.org/app/ 👈 Click "Save" then "Sign in". You should see your login page`,
|
||||
`- Got to 👉 http://localhost:8080/realms/myrealm/account 👈 to see your account theme`,
|
||||
|
@ -11,10 +11,6 @@ export type ParsedPackageJson = {
|
||||
version?: string;
|
||||
homepage?: string;
|
||||
keycloakify?: {
|
||||
/** @deprecated: use extraLoginPages instead */
|
||||
extraPages?: string[];
|
||||
extraLoginPages?: string[];
|
||||
extraAccountPages?: string[];
|
||||
extraThemeProperties?: string[];
|
||||
areAppAndKeycloakServerSharingSameDomain?: boolean;
|
||||
artifactId?: string;
|
||||
@ -23,8 +19,8 @@ export type ParsedPackageJson = {
|
||||
keycloakVersionDefaultAssets?: string;
|
||||
reactAppBuildDirPath?: string;
|
||||
keycloakifyBuildDirPath?: string;
|
||||
customUserAttributes?: string[];
|
||||
themeName?: string;
|
||||
extraThemeNames?: string[];
|
||||
};
|
||||
};
|
||||
|
||||
@ -34,9 +30,6 @@ export const zParsedPackageJson = z.object({
|
||||
"homepage": z.string().optional(),
|
||||
"keycloakify": z
|
||||
.object({
|
||||
"extraPages": z.array(z.string()).optional(),
|
||||
"extraLoginPages": z.array(z.string()).optional(),
|
||||
"extraAccountPages": z.array(z.string()).optional(),
|
||||
"extraThemeProperties": z.array(z.string()).optional(),
|
||||
"areAppAndKeycloakServerSharingSameDomain": z.boolean().optional(),
|
||||
"artifactId": z.string().optional(),
|
||||
@ -45,8 +38,8 @@ export const zParsedPackageJson = z.object({
|
||||
"keycloakVersionDefaultAssets": z.string().optional(),
|
||||
"reactAppBuildDirPath": z.string().optional(),
|
||||
"keycloakifyBuildDirPath": z.string().optional(),
|
||||
"customUserAttributes": z.array(z.string()).optional(),
|
||||
"themeName": z.string().optional()
|
||||
"themeName": z.string().optional(),
|
||||
"extraThemeNames": z.array(z.string()).optional()
|
||||
})
|
||||
.optional()
|
||||
});
|
||||
|
@ -1,31 +1,6 @@
|
||||
import { ftlValuesGlobalName } from "../ftlValuesGlobalName";
|
||||
import type { BuildOptions } from "../BuildOptions";
|
||||
import { assert } from "tsafe/assert";
|
||||
import { is } from "tsafe/is";
|
||||
import { Reflect } from "tsafe/Reflect";
|
||||
|
||||
export type BuildOptionsLike = BuildOptionsLike.Standalone | BuildOptionsLike.ExternalAssets;
|
||||
|
||||
export namespace BuildOptionsLike {
|
||||
export type Standalone = {
|
||||
isStandalone: true;
|
||||
};
|
||||
|
||||
export type ExternalAssets = {
|
||||
isStandalone: false;
|
||||
urlOrigin: string;
|
||||
};
|
||||
}
|
||||
|
||||
{
|
||||
const buildOptions = Reflect<BuildOptions>();
|
||||
|
||||
assert(!is<BuildOptions.ExternalAssets.CommonExternalAssets>(buildOptions));
|
||||
|
||||
assert<typeof buildOptions extends BuildOptionsLike ? true : false>();
|
||||
}
|
||||
|
||||
export function replaceImportsFromStaticInJsCode(params: { jsCode: string; buildOptions: BuildOptionsLike }): { fixedJsCode: string } {
|
||||
export function replaceImportsFromStaticInJsCode(params: { jsCode: string }): { fixedJsCode: string } {
|
||||
/*
|
||||
NOTE:
|
||||
|
||||
@ -38,48 +13,38 @@ export function replaceImportsFromStaticInJsCode(params: { jsCode: string; build
|
||||
will always run in keycloak context.
|
||||
*/
|
||||
|
||||
const { jsCode, buildOptions } = params;
|
||||
const { jsCode } = params;
|
||||
|
||||
const getReplaceArgs = (language: "js" | "css"): Parameters<typeof String.prototype.replace> => [
|
||||
new RegExp(`([a-zA-Z_]+)\\.([a-zA-Z]+)=function\\(([a-zA-Z]+)\\){return"static\\/${language}\\/"`, "g"),
|
||||
(...[, n, u, e]) => `
|
||||
${n}[(function(){
|
||||
var pd= Object.getOwnPropertyDescriptor(${n}, "p");
|
||||
new RegExp(`([a-zA-Z_]+)\\.([a-zA-Z]+)=(function\\(([a-z]+)\\){return|([a-z]+)=>)"static\\/${language}\\/"`, "g"),
|
||||
(...[, n, u, matchedFunction, eForFunction]) => {
|
||||
const isArrowFunction = matchedFunction.includes("=>");
|
||||
const e = isArrowFunction ? matchedFunction.replace("=>", "").trim() : eForFunction;
|
||||
|
||||
return `
|
||||
${n}[(function(){
|
||||
var pd = Object.getOwnPropertyDescriptor(${n}, "p");
|
||||
if( pd === undefined || pd.configurable ){
|
||||
${
|
||||
buildOptions.isStandalone
|
||||
? `
|
||||
Object.defineProperty(${n}, "p", {
|
||||
get: function() { return window.${ftlValuesGlobalName}.url.resourcesPath; },
|
||||
set: function (){}
|
||||
});
|
||||
`
|
||||
: `
|
||||
var p= "";
|
||||
Object.defineProperty(${n}, "p", {
|
||||
get: function() { return "${ftlValuesGlobalName}" in window ? "${buildOptions.urlOrigin}/" : p; },
|
||||
set: function (value){ p = value;}
|
||||
get: function() { return window.${ftlValuesGlobalName}.url.resourcesPath; },
|
||||
set: function() {}
|
||||
});
|
||||
`
|
||||
}
|
||||
}
|
||||
return "${u}";
|
||||
})()] = function(${e}) { return "${buildOptions.isStandalone ? "/build/" : ""}static/${language}/"`
|
||||
return "${u}";
|
||||
})()] = ${isArrowFunction ? `${e} =>` : `function(${e}) { return `} "/build/static/${language}/"`
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
];
|
||||
|
||||
const fixedJsCode = jsCode
|
||||
.replace(...getReplaceArgs("js"))
|
||||
.replace(...getReplaceArgs("css"))
|
||||
.replace(/([a-zA-Z]+\.[a-zA-Z]+)\+"static\//g, (...[, group]) =>
|
||||
buildOptions.isStandalone
|
||||
? `window.${ftlValuesGlobalName}.url.resourcesPath + "/build/static/`
|
||||
: `("${ftlValuesGlobalName}" in window ? "${buildOptions.urlOrigin}/" : ${group}) + "static/`
|
||||
)
|
||||
.replace(/[a-zA-Z]+\.[a-zA-Z]+\+"static\//g, `window.${ftlValuesGlobalName}.url.resourcesPath + "/build/static/`)
|
||||
//TODO: Write a test case for this
|
||||
.replace(/".chunk.css",([a-zA-Z])+=([a-zA-Z]+\.[a-zA-Z]+)\+([a-zA-Z]+),/, (...[, group1, group2, group3]) =>
|
||||
buildOptions.isStandalone
|
||||
? `".chunk.css",${group1} = window.${ftlValuesGlobalName}.url.resourcesPath + "/build/" + ${group3},`
|
||||
: `".chunk.css",${group1} = ("${ftlValuesGlobalName}" in window ? "${buildOptions.urlOrigin}/" : ${group2}) + ${group3},`
|
||||
.replace(
|
||||
/".chunk.css",([a-zA-Z])+=[a-zA-Z]+\.[a-zA-Z]+\+([a-zA-Z]+),/,
|
||||
(...[, group1, group2]) => `".chunk.css",${group1} = window.${ftlValuesGlobalName}.url.resourcesPath + "/build/" + ${group2},`
|
||||
);
|
||||
|
||||
return { fixedJsCode };
|
||||
|
@ -1,20 +1,12 @@
|
||||
import * as crypto from "crypto";
|
||||
import type { BuildOptions } from "../BuildOptions";
|
||||
import { assert } from "tsafe/assert";
|
||||
import { is } from "tsafe/is";
|
||||
import { Reflect } from "tsafe/Reflect";
|
||||
|
||||
export type BuildOptionsLike = {
|
||||
urlPathname: string | undefined;
|
||||
};
|
||||
|
||||
{
|
||||
const buildOptions = Reflect<BuildOptions>();
|
||||
|
||||
assert(!is<BuildOptions.ExternalAssets.CommonExternalAssets>(buildOptions));
|
||||
|
||||
assert<typeof buildOptions extends BuildOptionsLike ? true : false>();
|
||||
}
|
||||
assert<BuildOptions extends BuildOptionsLike ? true : false>();
|
||||
|
||||
export function replaceImportsInCssCode(params: { cssCode: string }): {
|
||||
fixedCssCode: string;
|
||||
|
@ -1,32 +1,11 @@
|
||||
import type { BuildOptions } from "../BuildOptions";
|
||||
import { assert } from "tsafe/assert";
|
||||
import { is } from "tsafe/is";
|
||||
import { Reflect } from "tsafe/Reflect";
|
||||
|
||||
export type BuildOptionsLike = BuildOptionsLike.Standalone | BuildOptionsLike.ExternalAssets;
|
||||
export type BuildOptionsLike = {
|
||||
urlPathname: string | undefined;
|
||||
};
|
||||
|
||||
export namespace BuildOptionsLike {
|
||||
export type Common = {
|
||||
urlPathname: string | undefined;
|
||||
};
|
||||
|
||||
export type Standalone = Common & {
|
||||
isStandalone: true;
|
||||
};
|
||||
|
||||
export type ExternalAssets = Common & {
|
||||
isStandalone: false;
|
||||
urlOrigin: string;
|
||||
};
|
||||
}
|
||||
|
||||
{
|
||||
const buildOptions = Reflect<BuildOptions>();
|
||||
|
||||
assert(!is<BuildOptions.ExternalAssets.CommonExternalAssets>(buildOptions));
|
||||
|
||||
assert<typeof buildOptions extends BuildOptionsLike ? true : false>();
|
||||
}
|
||||
assert<BuildOptions extends BuildOptionsLike ? true : false>();
|
||||
|
||||
export function replaceImportsInInlineCssCode(params: { cssCode: string; buildOptions: BuildOptionsLike }): {
|
||||
fixedCssCode: string;
|
||||
@ -37,10 +16,7 @@ export function replaceImportsInInlineCssCode(params: { cssCode: string; buildOp
|
||||
buildOptions.urlPathname === undefined
|
||||
? /url\(["']?\/([^/][^)"']+)["']?\)/g
|
||||
: new RegExp(`url\\(["']?${buildOptions.urlPathname}([^)"']+)["']?\\)`, "g"),
|
||||
(...[, group]) =>
|
||||
`url(${
|
||||
buildOptions.isStandalone ? "${url.resourcesPath}/build/" + group : buildOptions.urlOrigin + (buildOptions.urlPathname ?? "/") + group
|
||||
})`
|
||||
(...[, group]) => `url(\${url.resourcesPath}/build/${group})`
|
||||
);
|
||||
|
||||
return { fixedCssCode };
|
||||
|
@ -1,5 +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");
|
||||
export const basenameOfKeycloakDirInPublicDir = "keycloak-resources";
|
||||
export const resourcesDirPathRelativeToPublicDir = pathJoin(basenameOfKeycloakDirInPublicDir, "resources");
|
||||
export const resourcesCommonDirPathRelativeToPublicDir = pathJoin(resourcesDirPathRelativeToPublicDir, "resources_common");
|
||||
|
@ -17,7 +17,7 @@ export async function promptKeycloakVersion() {
|
||||
return { getLatestsSemVersionedTag };
|
||||
})();
|
||||
|
||||
console.log("Initialize the directory with email template from which keycloak version?");
|
||||
console.log("Select Keycloak version?");
|
||||
|
||||
const tags = [
|
||||
...(await getLatestsSemVersionedTag({
|
||||
|
@ -1,15 +0,0 @@
|
||||
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
|
||||
};
|
||||
};
|
@ -1,27 +1,32 @@
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
|
||||
/** List all files in a given directory return paths relative to the dir_path */
|
||||
export const crawl = (() => {
|
||||
const crawlRec = (dir_path: string, paths: string[]) => {
|
||||
for (const file_name of fs.readdirSync(dir_path)) {
|
||||
const file_path = path.join(dir_path, file_name);
|
||||
const crawlRec = (dir_path: string, paths: string[]) => {
|
||||
for (const file_name of fs.readdirSync(dir_path)) {
|
||||
const file_path = path.join(dir_path, file_name);
|
||||
|
||||
if (fs.lstatSync(file_path).isDirectory()) {
|
||||
crawlRec(file_path, paths);
|
||||
if (fs.lstatSync(file_path).isDirectory()) {
|
||||
crawlRec(file_path, paths);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
paths.push(file_path);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
return function crawl(dir_path: string): string[] {
|
||||
const paths: string[] = [];
|
||||
paths.push(file_path);
|
||||
}
|
||||
};
|
||||
|
||||
crawlRec(dir_path, paths);
|
||||
/** List all files in a given directory return paths relative to the dir_path */
|
||||
export function crawl(params: { dirPath: string; returnedPathsType: "absolute" | "relative to dirPath" }): string[] {
|
||||
const { dirPath, returnedPathsType } = params;
|
||||
|
||||
return paths.map(file_path => path.relative(dir_path, file_path));
|
||||
};
|
||||
})();
|
||||
const filePaths: string[] = [];
|
||||
|
||||
crawlRec(dirPath, filePaths);
|
||||
|
||||
switch (returnedPathsType) {
|
||||
case "absolute":
|
||||
return filePaths;
|
||||
case "relative to dirPath":
|
||||
return filePaths.map(filePath => path.relative(dirPath, filePath));
|
||||
}
|
||||
}
|
||||
|
@ -1,18 +1,55 @@
|
||||
import { exec as execCallback } from "child_process";
|
||||
import { createHash } from "crypto";
|
||||
import { mkdir, stat, writeFile } from "fs/promises";
|
||||
import { mkdir, readFile, stat, writeFile, unlink, rm } from "fs/promises";
|
||||
import fetch, { type FetchOptions } from "make-fetch-happen";
|
||||
import { dirname as pathDirname, join as pathJoin } from "path";
|
||||
import { assert } from "tsafe";
|
||||
import { dirname as pathDirname, join as pathJoin, resolve as pathResolve, sep as pathSep } from "path";
|
||||
import { assert } from "tsafe/assert";
|
||||
import { promisify } from "util";
|
||||
import { getProjectRoot } from "./getProjectRoot";
|
||||
import { transformCodebase } from "./transformCodebase";
|
||||
import { unzip } from "./unzip";
|
||||
import { unzip, zip } from "./unzip";
|
||||
|
||||
const exec = promisify(execCallback);
|
||||
|
||||
function hash(s: string) {
|
||||
return createHash("sha256").update(s).digest("hex");
|
||||
function generateFileNameFromURL(params: {
|
||||
url: string;
|
||||
preCacheTransform:
|
||||
| {
|
||||
actionCacheId: string;
|
||||
actionFootprint: string;
|
||||
}
|
||||
| undefined;
|
||||
}): string {
|
||||
const { preCacheTransform } = params;
|
||||
|
||||
// Parse the URL
|
||||
const url = new URL(params.url);
|
||||
|
||||
// Extract pathname and remove leading slashes
|
||||
let fileName = url.pathname.replace(/^\//, "").replace(/\//g, "_");
|
||||
|
||||
// Optionally, add query parameters replacing special characters
|
||||
if (url.search) {
|
||||
fileName += url.search.replace(/[&=?]/g, "-");
|
||||
}
|
||||
|
||||
// Replace any characters that are not valid in filenames
|
||||
fileName = fileName.replace(/[^a-zA-Z0-9-_]/g, "");
|
||||
|
||||
// Trim or pad the fileName to a specific length
|
||||
fileName = fileName.substring(0, 50);
|
||||
|
||||
add_pre_cache_transform: {
|
||||
if (preCacheTransform === undefined) {
|
||||
break add_pre_cache_transform;
|
||||
}
|
||||
|
||||
// Sanitize actionCacheId the same way as other components
|
||||
const sanitizedActionCacheId = preCacheTransform.actionCacheId.replace(/[^a-zA-Z0-9-_]/g, "_");
|
||||
|
||||
fileName += `_${sanitizedActionCacheId}_${createHash("sha256").update(preCacheTransform.actionFootprint).digest("hex").substring(0, 5)}`;
|
||||
}
|
||||
|
||||
return fileName;
|
||||
}
|
||||
|
||||
async function exists(path: string) {
|
||||
@ -25,46 +62,135 @@ async function exists(path: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function ensureArray<T>(arg0: T | T[]) {
|
||||
return Array.isArray(arg0) ? arg0 : typeof arg0 === "undefined" ? [] : [arg0];
|
||||
}
|
||||
|
||||
function ensureSingleOrNone<T>(arg0: T | T[]) {
|
||||
if (!Array.isArray(arg0)) return arg0;
|
||||
if (arg0.length === 0) return undefined;
|
||||
if (arg0.length === 1) return arg0[0];
|
||||
throw new Error("Illegal configuration, expected a single value but found multiple: " + arg0.map(String).join(", "));
|
||||
}
|
||||
|
||||
type NPMConfig = Record<string, string | string[]>;
|
||||
|
||||
const npmConfigReducer = (cfg: NPMConfig, [key, value]: [string, string]) =>
|
||||
key in cfg ? { ...cfg, [key]: [...ensureArray(cfg[key]), value] } : { ...cfg, [key]: value };
|
||||
|
||||
/**
|
||||
* Get npm configuration as map
|
||||
*/
|
||||
async function getNmpConfig(): Promise<Record<string, string>> {
|
||||
const { stdout } = await exec("npm config get", { encoding: "utf8" });
|
||||
async function getNmpConfig() {
|
||||
return readNpmConfig().then(parseNpmConfig);
|
||||
}
|
||||
|
||||
function readNpmConfig(): Promise<string> {
|
||||
return (async function callee(depth: number): Promise<string> {
|
||||
const cwd = pathResolve(pathJoin(...[process.cwd(), ...Array(depth).fill("..")]));
|
||||
|
||||
let stdout: string;
|
||||
|
||||
try {
|
||||
stdout = await exec("npm config get", { "encoding": "utf8", cwd }).then(({ stdout }) => stdout);
|
||||
} catch (error) {
|
||||
if (String(error).includes("ENOWORKSPACES")) {
|
||||
assert(cwd !== pathSep);
|
||||
|
||||
return callee(depth + 1);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
return stdout;
|
||||
})(0);
|
||||
}
|
||||
|
||||
function parseNpmConfig(stdout: string) {
|
||||
return stdout
|
||||
.split("\n")
|
||||
.filter(line => !line.startsWith(";"))
|
||||
.map(line => line.trim())
|
||||
.map(line => line.split("=", 2))
|
||||
.reduce((cfg, [key, value]) => ({ ...cfg, [key]: value }), {});
|
||||
.map(line => line.split("=", 2) as [string, string])
|
||||
.reduce(npmConfigReducer, {} as NPMConfig);
|
||||
}
|
||||
|
||||
function maybeBoolean(arg0: string | undefined) {
|
||||
return typeof arg0 === "undefined" ? undefined : Boolean(arg0);
|
||||
}
|
||||
|
||||
function chunks<T>(arr: T[], size: number = 2) {
|
||||
return arr.map((_, i) => i % size == 0 && arr.slice(i, i + size)).filter(Boolean) as T[][];
|
||||
}
|
||||
|
||||
async function readCafile(cafile: string) {
|
||||
const cafileContent = await readFile(cafile, "utf-8");
|
||||
return chunks(cafileContent.split(/(-----END CERTIFICATE-----)/), 2).map(ca => ca.join("").replace(/^\n/, "").replace(/\n/g, "\\n"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get proxy configuration from npm config files. Note that we don't care about
|
||||
* Get proxy and ssl configuration from npm config files. Note that we don't care about
|
||||
* proxy config in env vars, because make-fetch-happen will do that for us.
|
||||
*
|
||||
* @returns proxy configuration
|
||||
*/
|
||||
async function getNpmProxyConfig(): Promise<Pick<FetchOptions, "proxy" | "noProxy">> {
|
||||
async function getFetchOptions(): Promise<Pick<FetchOptions, "proxy" | "noProxy" | "strictSSL" | "ca" | "cert">> {
|
||||
const cfg = await getNmpConfig();
|
||||
|
||||
const proxy = cfg["https-proxy"] ?? cfg["proxy"];
|
||||
const proxy = ensureSingleOrNone(cfg["https-proxy"] ?? cfg["proxy"]);
|
||||
const noProxy = cfg["noproxy"] ?? cfg["no-proxy"];
|
||||
const strictSSL = maybeBoolean(ensureSingleOrNone(cfg["strict-ssl"]));
|
||||
const cert = cfg["cert"];
|
||||
const ca = ensureArray(cfg["ca"] ?? cfg["ca[]"]);
|
||||
const cafile = ensureSingleOrNone(cfg["cafile"]);
|
||||
|
||||
return { proxy, noProxy };
|
||||
if (typeof cafile !== "undefined" && cafile !== "null") ca.push(...(await readCafile(cafile)));
|
||||
|
||||
return { proxy, noProxy, strictSSL, cert, ca: ca.length === 0 ? undefined : ca };
|
||||
}
|
||||
|
||||
export async function downloadAndUnzip(params: { url: string; destDirPath: string; pathOfDirToExtractInArchive?: string }) {
|
||||
const { url, destDirPath, pathOfDirToExtractInArchive } = params;
|
||||
export async function downloadAndUnzip(
|
||||
params: {
|
||||
url: string;
|
||||
destDirPath: string;
|
||||
specificDirsToExtract?: string[];
|
||||
preCacheTransform?: {
|
||||
actionCacheId: string;
|
||||
action: (params: { destDirPath: string }) => Promise<void>;
|
||||
};
|
||||
} & (
|
||||
| {
|
||||
doUseCache: true;
|
||||
projectDirPath: string;
|
||||
}
|
||||
| {
|
||||
doUseCache: false;
|
||||
}
|
||||
)
|
||||
) {
|
||||
const { url, destDirPath, specificDirsToExtract, preCacheTransform, ...rest } = params;
|
||||
|
||||
const downloadHash = hash(JSON.stringify({ url })).substring(0, 15);
|
||||
const projectRoot = getProjectRoot();
|
||||
const cacheRoot = process.env.XDG_CACHE_HOME ?? pathJoin(projectRoot, "node_modules", ".cache");
|
||||
const zipFilePath = pathJoin(cacheRoot, "keycloakify", "zip", `_${downloadHash}.zip`);
|
||||
const extractDirPath = pathJoin(cacheRoot, "keycloakify", "unzip", `_${downloadHash}`);
|
||||
const zipFileBasename = generateFileNameFromURL({
|
||||
url,
|
||||
"preCacheTransform":
|
||||
preCacheTransform === undefined
|
||||
? undefined
|
||||
: {
|
||||
"actionCacheId": preCacheTransform.actionCacheId,
|
||||
"actionFootprint": preCacheTransform.action.toString()
|
||||
}
|
||||
});
|
||||
|
||||
const cacheRoot = !rest.doUseCache
|
||||
? `tmp_${Math.random().toString().slice(2, 12)}`
|
||||
: pathJoin(process.env.XDG_CACHE_HOME ?? pathJoin(rest.projectDirPath, "node_modules", ".cache"), "keycloakify");
|
||||
const zipFilePath = pathJoin(cacheRoot, `${zipFileBasename}.zip`);
|
||||
const extractDirPath = pathJoin(cacheRoot, `tmp_unzip_${zipFileBasename}`);
|
||||
|
||||
if (!(await exists(zipFilePath))) {
|
||||
const proxyOpts = await getNpmProxyConfig();
|
||||
const response = await fetch(url, proxyOpts);
|
||||
const opts = await getFetchOptions();
|
||||
const response = await fetch(url, opts);
|
||||
await mkdir(pathDirname(zipFilePath), { "recursive": true });
|
||||
/**
|
||||
* The correct way to fix this is to upgrade node-fetch beyond 3.2.5
|
||||
@ -76,12 +202,32 @@ export async function downloadAndUnzip(params: { url: string; destDirPath: strin
|
||||
response.body?.setMaxListeners(Number.MAX_VALUE);
|
||||
assert(typeof response.body !== "undefined" && response.body != null);
|
||||
await writeFile(zipFilePath, response.body);
|
||||
|
||||
if (specificDirsToExtract !== undefined || preCacheTransform !== undefined) {
|
||||
await unzip(zipFilePath, extractDirPath, specificDirsToExtract);
|
||||
|
||||
await preCacheTransform?.action({
|
||||
"destDirPath": extractDirPath
|
||||
});
|
||||
|
||||
await unlink(zipFilePath);
|
||||
|
||||
await zip(extractDirPath, zipFilePath);
|
||||
|
||||
await rm(extractDirPath, { "recursive": true });
|
||||
}
|
||||
}
|
||||
|
||||
await unzip(zipFilePath, extractDirPath, pathOfDirToExtractInArchive);
|
||||
await unzip(zipFilePath, extractDirPath);
|
||||
|
||||
transformCodebase({
|
||||
"srcDirPath": extractDirPath,
|
||||
"destDirPath": destDirPath
|
||||
});
|
||||
|
||||
if (!rest.doUseCache) {
|
||||
await rm(cacheRoot, { "recursive": true });
|
||||
} else {
|
||||
await rm(extractDirPath, { "recursive": true });
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { dirname, relative, sep } from "path";
|
||||
import { dirname, relative, sep, join } from "path";
|
||||
import { createWriteStream } from "fs";
|
||||
|
||||
import walk from "./walk";
|
||||
@ -48,8 +48,12 @@ export async function jarStream({ groupId, artifactId, version, asyncPathGenerat
|
||||
for await (const entry of asyncPathGeneratorFn()) {
|
||||
if ("buffer" in entry) {
|
||||
zipFile.addBuffer(entry.buffer, entry.zipPath);
|
||||
} else if ("fsPath" in entry && !entry.fsPath.endsWith(sep)) {
|
||||
zipFile.addFile(entry.fsPath, entry.zipPath);
|
||||
} else if ("fsPath" in entry) {
|
||||
if (entry.fsPath.endsWith(sep)) {
|
||||
zipFile.addEmptyDirectory(entry.zipPath);
|
||||
} else {
|
||||
zipFile.addFile(entry.fsPath, entry.zipPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -65,15 +69,23 @@ export async function jarStream({ groupId, artifactId, version, asyncPathGenerat
|
||||
* Create a jar archive, using the resources found at `rootPath` (a directory) and write the
|
||||
* archive to `targetPath` (a file). Use `groupId`, `artifactId` and `version` to define
|
||||
* the contents of the pom.properties file which is going to be added to the archive.
|
||||
* The root directory is expectedto have a conventional maven/gradle folder structure with a
|
||||
* single `pom.xml` file at the root and a `src/main/resources` directory containing all
|
||||
* application resources.
|
||||
*/
|
||||
export default async function jar({ groupId, artifactId, version, rootPath, targetPath }: JarArgs) {
|
||||
await mkdir(dirname(targetPath), { recursive: true });
|
||||
|
||||
const asyncPathGeneratorFn = async function* (): ZipEntryGenerator {
|
||||
for await (const fsPath of walk(rootPath)) {
|
||||
const zipPath = relative(rootPath, fsPath).split(sep).join("/");
|
||||
const resourcesPath = join(rootPath, "src", "main", "resources");
|
||||
for await (const fsPath of walk(resourcesPath)) {
|
||||
const zipPath = relative(resourcesPath, fsPath).split(sep).join("/");
|
||||
yield { fsPath, zipPath };
|
||||
}
|
||||
yield {
|
||||
fsPath: join(rootPath, "pom.xml"),
|
||||
zipPath: `META-INF/maven/${groupId}/${artifactId}/pom.xml`
|
||||
};
|
||||
};
|
||||
|
||||
const zipFile = await jarStream({ groupId, artifactId, version, asyncPathGeneratorFn });
|
||||
|
@ -3,7 +3,7 @@ import * as path from "path";
|
||||
import { crawl } from "./crawl";
|
||||
import { id } from "tsafe/id";
|
||||
|
||||
type TransformSourceCode = (params: { sourceCode: Buffer; filePath: string }) =>
|
||||
type TransformSourceCode = (params: { sourceCode: Buffer; filePath: string; fileRelativePath: string }) =>
|
||||
| {
|
||||
modifiedSourceCode: Buffer;
|
||||
newFileName?: string;
|
||||
@ -20,26 +20,27 @@ export function transformCodebase(params: { srcDirPath: string; destDirPath: str
|
||||
}))
|
||||
} = params;
|
||||
|
||||
for (const file_relative_path of crawl(srcDirPath)) {
|
||||
const filePath = path.join(srcDirPath, file_relative_path);
|
||||
for (const fileRelativePath of crawl({ "dirPath": srcDirPath, "returnedPathsType": "relative to dirPath" })) {
|
||||
const filePath = path.join(srcDirPath, fileRelativePath);
|
||||
|
||||
const transformSourceCodeResult = transformSourceCode({
|
||||
"sourceCode": fs.readFileSync(filePath),
|
||||
"filePath": path.join(srcDirPath, file_relative_path)
|
||||
filePath,
|
||||
fileRelativePath
|
||||
});
|
||||
|
||||
if (transformSourceCodeResult === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
fs.mkdirSync(path.dirname(path.join(destDirPath, file_relative_path)), {
|
||||
fs.mkdirSync(path.dirname(path.join(destDirPath, fileRelativePath)), {
|
||||
"recursive": true
|
||||
});
|
||||
|
||||
const { newFileName, modifiedSourceCode } = transformSourceCodeResult;
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(path.dirname(path.join(destDirPath, file_relative_path)), newFileName ?? path.basename(file_relative_path)),
|
||||
path.join(path.dirname(path.join(destDirPath, fileRelativePath)), newFileName ?? path.basename(fileRelativePath)),
|
||||
modifiedSourceCode
|
||||
);
|
||||
}
|
||||
|
@ -9,7 +9,7 @@ function populateTemplate(strings: TemplateStringsArray, ...args: unknown[]) {
|
||||
if (strings[i]) {
|
||||
chunks.push(strings[i]);
|
||||
// remember last indent of the string portion
|
||||
lastStringLineLength = strings[i].split("\n").at(-1)?.length ?? 0;
|
||||
lastStringLineLength = strings[i].split("\n").slice(-1)[0]?.length ?? 0;
|
||||
}
|
||||
if (args[i]) {
|
||||
// if the interpolation value has newlines, indent the interpolation values
|
||||
|
@ -2,6 +2,7 @@ import fsp from "node:fs/promises";
|
||||
import fs from "fs";
|
||||
import path from "node:path";
|
||||
import yauzl from "yauzl";
|
||||
import yazl from "yazl";
|
||||
import stream from "node:stream";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
@ -19,11 +20,16 @@ async function pathExists(path: string) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function unzip(file: string, targetFolder: string, unzipSubPath?: string) {
|
||||
// add trailing slash to unzipSubPath and targetFolder
|
||||
if (unzipSubPath && (!unzipSubPath.endsWith("/") || !unzipSubPath.endsWith("\\"))) {
|
||||
unzipSubPath += "/";
|
||||
}
|
||||
// Handlings of non posix path is not implemented correctly
|
||||
// it work by coincidence. Don't have the time to fix but it should be fixed.
|
||||
export async function unzip(file: string, targetFolder: string, specificDirsToExtract?: string[]) {
|
||||
specificDirsToExtract = specificDirsToExtract?.map(dirPath => {
|
||||
if (!dirPath.endsWith("/") || !dirPath.endsWith("\\")) {
|
||||
dirPath += "/";
|
||||
}
|
||||
|
||||
return dirPath;
|
||||
});
|
||||
|
||||
if (!targetFolder.endsWith("/") || !targetFolder.endsWith("\\")) {
|
||||
targetFolder += "/";
|
||||
@ -42,15 +48,17 @@ export async function unzip(file: string, targetFolder: string, unzipSubPath?: s
|
||||
zipfile.readEntry();
|
||||
|
||||
zipfile.on("entry", async entry => {
|
||||
if (unzipSubPath) {
|
||||
if (specificDirsToExtract !== undefined) {
|
||||
const dirPath = specificDirsToExtract.find(dirPath => entry.fileName.startsWith(dirPath));
|
||||
|
||||
// Skip files outside of the unzipSubPath
|
||||
if (!entry.fileName.startsWith(unzipSubPath)) {
|
||||
if (dirPath === undefined) {
|
||||
zipfile.readEntry();
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove the unzipSubPath from the file name
|
||||
entry.fileName = entry.fileName.substring(unzipSubPath.length);
|
||||
entry.fileName = entry.fileName.substring(dirPath.length);
|
||||
}
|
||||
|
||||
const target = path.join(targetFolder, entry.fileName);
|
||||
@ -77,6 +85,8 @@ export async function unzip(file: string, targetFolder: string, unzipSubPath?: s
|
||||
return;
|
||||
}
|
||||
|
||||
await fsp.mkdir(path.dirname(target), { "recursive": true });
|
||||
|
||||
await pipeline(readStream, fs.createWriteStream(target));
|
||||
|
||||
zipfile.readEntry();
|
||||
@ -90,3 +100,42 @@ export async function unzip(file: string, targetFolder: string, unzipSubPath?: s
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// NOTE: This code was directly copied from ChatGPT and appears to function as expected.
|
||||
// However, confidence in its complete accuracy and robustness is limited.
|
||||
export async function zip(sourceFolder: string, targetZip: string) {
|
||||
return new Promise<void>(async (resolve, reject) => {
|
||||
const zipfile = new yazl.ZipFile();
|
||||
const files: string[] = [];
|
||||
|
||||
// Recursive function to explore directories and their subdirectories
|
||||
async function exploreDir(dir: string) {
|
||||
const dirContent = await fsp.readdir(dir);
|
||||
for (const file of dirContent) {
|
||||
const filePath = path.join(dir, file);
|
||||
const stat = await fsp.stat(filePath);
|
||||
if (stat.isDirectory()) {
|
||||
await exploreDir(filePath);
|
||||
} else if (stat.isFile()) {
|
||||
files.push(filePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Collecting all files to be zipped
|
||||
await exploreDir(sourceFolder);
|
||||
|
||||
// Adding files to zip
|
||||
for (const file of files) {
|
||||
const relativePath = path.relative(sourceFolder, file);
|
||||
zipfile.addFile(file, relativePath);
|
||||
}
|
||||
|
||||
zipfile.outputStream
|
||||
.pipe(fs.createWriteStream(targetZip))
|
||||
.on("close", () => resolve())
|
||||
.on("error", err => reject(err)); // Listen to error events
|
||||
|
||||
zipfile.end();
|
||||
});
|
||||
}
|
||||
|
@ -1,21 +1,15 @@
|
||||
import { useReducer, useEffect } from "react";
|
||||
import { headInsert } from "keycloakify/tools/headInsert";
|
||||
import { pathJoin } from "keycloakify/bin/tools/pathJoin";
|
||||
import { clsx } from "keycloakify/tools/clsx";
|
||||
|
||||
export function usePrepareTemplate(params: {
|
||||
doFetchDefaultThemeResources: boolean;
|
||||
stylesCommon?: string[];
|
||||
styles?: string[];
|
||||
scripts?: string[];
|
||||
url: {
|
||||
resourcesCommonPath: string;
|
||||
resourcesPath: string;
|
||||
};
|
||||
htmlClassName: string | undefined;
|
||||
bodyClassName: string | undefined;
|
||||
}) {
|
||||
const { doFetchDefaultThemeResources, stylesCommon, styles, url, scripts, htmlClassName, bodyClassName } = params;
|
||||
const { doFetchDefaultThemeResources, styles = [], scripts = [], htmlClassName, bodyClassName } = params;
|
||||
|
||||
const [isReady, setReady] = useReducer(() => true, !doFetchDefaultThemeResources);
|
||||
|
||||
@ -26,36 +20,41 @@ export function usePrepareTemplate(params: {
|
||||
|
||||
let isUnmounted = false;
|
||||
|
||||
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;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
setReady();
|
||||
});
|
||||
})();
|
||||
|
||||
(scripts ?? []).forEach(relativePath =>
|
||||
headInsert({
|
||||
scripts.forEach(src => {
|
||||
const { remove } = headInsert({
|
||||
"type": "javascript",
|
||||
"src": pathJoin(url.resourcesPath, relativePath)
|
||||
})
|
||||
);
|
||||
src
|
||||
});
|
||||
|
||||
removeArray.push(remove);
|
||||
});
|
||||
|
||||
return () => {
|
||||
isUnmounted = true;
|
||||
removeArray.forEach(remove => remove());
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
@ -27,6 +27,7 @@ const UpdateUserProfile = lazy(() => import("keycloakify/login/pages/UpdateUserP
|
||||
const IdpReviewUserProfile = lazy(() => import("keycloakify/login/pages/IdpReviewUserProfile"));
|
||||
const UpdateEmail = lazy(() => import("keycloakify/login/pages/UpdateEmail"));
|
||||
const SelectAuthenticator = lazy(() => import("keycloakify/login/pages/SelectAuthenticator"));
|
||||
const SamlPostForm = lazy(() => import("keycloakify/login/pages/SamlPostForm"));
|
||||
|
||||
export default function Fallback(props: PageProps<KcContext, I18n>) {
|
||||
const { kcContext, ...rest } = props;
|
||||
@ -81,6 +82,8 @@ export default function Fallback(props: PageProps<KcContext, I18n>) {
|
||||
return <UpdateEmail kcContext={kcContext} {...rest} />;
|
||||
case "select-authenticator.ftl":
|
||||
return <SelectAuthenticator kcContext={kcContext} {...rest} />;
|
||||
case "saml-post-form.ftl":
|
||||
return <SamlPostForm kcContext={kcContext} {...rest} />;
|
||||
}
|
||||
assert<Equals<typeof kcContext, never>>(false);
|
||||
})()}
|
||||
|
@ -31,15 +31,14 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
|
||||
|
||||
const { isReady } = usePrepareTemplate({
|
||||
"doFetchDefaultThemeResources": doUseDefaultCss,
|
||||
url,
|
||||
"stylesCommon": [
|
||||
"node_modules/patternfly/dist/css/patternfly.min.css",
|
||||
"node_modules/patternfly/dist/css/patternfly-additions.min.css",
|
||||
"lib/zocial/zocial.css"
|
||||
"styles": [
|
||||
`${url.resourcesCommonPath}/node_modules/patternfly/dist/css/patternfly.min.css`,
|
||||
`${url.resourcesCommonPath}/node_modules/patternfly/dist/css/patternfly-additions.min.css`,
|
||||
`${url.resourcesCommonPath}/lib/zocial/zocial.css`,
|
||||
`${url.resourcesPath}/css/login.css`
|
||||
],
|
||||
"styles": ["css/login.css"],
|
||||
"htmlClassName": getClassName("kcHtmlClass"),
|
||||
"bodyClassName": undefined
|
||||
"bodyClassName": getClassName("kcBodyClass")
|
||||
});
|
||||
|
||||
if (!isReady) {
|
||||
|
@ -21,6 +21,7 @@ export type TemplateProps<KcContext extends KcContext.Common, I18nExtended exten
|
||||
};
|
||||
|
||||
export type ClassKey =
|
||||
| "kcBodyClass"
|
||||
| "kcHtmlClass"
|
||||
| "kcLoginClass"
|
||||
| "kcHeaderClass"
|
||||
|
@ -211,7 +211,8 @@ const keycloakifyExtraMessages = {
|
||||
"shouldBeDifferent": "{0} should be different to {1}",
|
||||
"shouldMatchPattern": "Pattern should match: `/{0}/`",
|
||||
"mustBeAnInteger": "Must be an integer",
|
||||
"notAValidOption": "Not a valid option"
|
||||
"notAValidOption": "Not a valid option",
|
||||
"selectAnOption": "Select an option"
|
||||
},
|
||||
"fr": {
|
||||
/* spell-checker: disable */
|
||||
@ -223,7 +224,8 @@ const keycloakifyExtraMessages = {
|
||||
|
||||
"logoutConfirmTitle": "Déconnexion",
|
||||
"logoutConfirmHeader": "Êtes-vous sûr(e) de vouloir vous déconnecter ?",
|
||||
"doLogout": "Se déconnecter"
|
||||
"doLogout": "Se déconnecter",
|
||||
"selectAnOption": "Sélectionner une option"
|
||||
/* spell-checker: enable */
|
||||
}
|
||||
};
|
||||
|
@ -5,6 +5,7 @@ export default Fallback;
|
||||
export { useDownloadTerms } from "keycloakify/login/lib/useDownloadTerms";
|
||||
export { getKcContext } from "keycloakify/login/kcContext/getKcContext";
|
||||
export { createGetKcContext } from "keycloakify/login/kcContext/createGetKcContext";
|
||||
export type { LoginThemePageId as PageId } from "keycloakify/bin/keycloakify/generateFtl";
|
||||
export { createUseI18n } from "keycloakify/login/i18n/i18n";
|
||||
|
||||
export type { PageProps } from "keycloakify/login/pages/PageProps";
|
||||
|
@ -1,4 +1,4 @@
|
||||
import type { LoginThemePageId } from "keycloakify/bin/keycloakify/generateFtl";
|
||||
import type { LoginThemePageId, ThemeType } from "keycloakify/bin/keycloakify/generateFtl";
|
||||
import { assert } from "tsafe/assert";
|
||||
import type { Equals } from "tsafe";
|
||||
import type { MessageKey } from "../i18n/i18n";
|
||||
@ -32,11 +32,14 @@ export type KcContext =
|
||||
| KcContext.UpdateUserProfile
|
||||
| KcContext.IdpReviewUserProfile
|
||||
| KcContext.UpdateEmail
|
||||
| KcContext.SelectAuthenticator;
|
||||
| KcContext.SelectAuthenticator
|
||||
| KcContext.SamlPostForm;
|
||||
|
||||
export declare namespace KcContext {
|
||||
export type Common = {
|
||||
keycloakifyVersion: string;
|
||||
themeType: "login";
|
||||
themeName: string;
|
||||
url: {
|
||||
loginAction: string;
|
||||
resourcesPath: string;
|
||||
@ -78,13 +81,48 @@ export declare namespace KcContext {
|
||||
};
|
||||
isAppInitiatedAction: boolean;
|
||||
messagesPerField: {
|
||||
printIfExists: <T>(fieldName: string, x: T) => T | undefined;
|
||||
/**
|
||||
* Return text if message for given field exists. Useful eg. to add css styles for fields with message.
|
||||
*
|
||||
* @param fieldName to check for
|
||||
* @param text to return
|
||||
* @return text if message exists for given field, else undefined
|
||||
*/
|
||||
printIfExists: <T extends string>(fieldName: string, text: T) => T | undefined;
|
||||
/**
|
||||
* Check if exists error message for given fields
|
||||
*
|
||||
* @param fields
|
||||
* @return boolean
|
||||
*/
|
||||
existsError: (fieldName: string) => boolean;
|
||||
/**
|
||||
* Get message for given field.
|
||||
*
|
||||
* @param fieldName
|
||||
* @return message text or empty string
|
||||
*/
|
||||
get: (fieldName: string) => string;
|
||||
/**
|
||||
* Check if message for given field exists
|
||||
*
|
||||
* @param field
|
||||
* @return boolean
|
||||
*/
|
||||
exists: (fieldName: string) => boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export type SamlPostForm = Common & {
|
||||
pageId: "saml-post-form.ftl";
|
||||
samlPost: {
|
||||
url: string;
|
||||
SAMLRequest?: string;
|
||||
SAMLResponse?: string;
|
||||
relayState?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type Login = Common & {
|
||||
pageId: "login.ftl";
|
||||
url: {
|
||||
@ -107,7 +145,7 @@ export declare namespace KcContext {
|
||||
rememberMe?: string;
|
||||
password?: string;
|
||||
};
|
||||
usernameEditDisabled: boolean;
|
||||
usernameHidden?: boolean;
|
||||
social: {
|
||||
displayInfo: boolean;
|
||||
providers?: {
|
||||
@ -333,7 +371,6 @@ export declare namespace KcContext {
|
||||
totpSecretEncoded: string;
|
||||
qrUrl: string;
|
||||
policy: {
|
||||
supportedApplications: string[];
|
||||
algorithm: "HmacSHA1" | "HmacSHA256" | "HmacSHA512";
|
||||
digits: number;
|
||||
lookAheadWindow: number;
|
||||
@ -347,6 +384,7 @@ export declare namespace KcContext {
|
||||
initialCounter: number;
|
||||
}
|
||||
);
|
||||
supportedApplications: string[];
|
||||
totpSecretQrCode: string;
|
||||
manualUrl: string;
|
||||
totpSecret: string;
|
||||
@ -543,4 +581,15 @@ export declare namespace Validators {
|
||||
};
|
||||
}
|
||||
|
||||
assert<Equals<KcContext["pageId"], LoginThemePageId>>();
|
||||
{
|
||||
type Got = KcContext["pageId"];
|
||||
type Expected = LoginThemePageId;
|
||||
|
||||
type OnlyInGot = Exclude<Got, Expected>;
|
||||
type OnlyInExpected = Exclude<Expected, Got>;
|
||||
|
||||
assert<Equals<OnlyInGot, never>>();
|
||||
assert<Equals<OnlyInExpected, never>>();
|
||||
}
|
||||
|
||||
assert<KcContext["themeType"] extends ThemeType ? true : false>();
|
||||
|
@ -9,9 +9,8 @@ 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 { resourcesCommonDirPathRelativeToPublicDir } from "keycloakify/bin/mockTestingResourcesPath";
|
||||
import { symToStr } from "tsafe/symToStr";
|
||||
import { loginThemePageIds } from "keycloakify/bin/keycloakify/generateFtl/pageId";
|
||||
|
||||
export function createGetKcContext<KcContextExtension extends { pageId: string } = never>(params?: {
|
||||
mockData?: readonly DeepPartial<ExtendKcContext<KcContextExtension>>[];
|
||||
@ -33,13 +32,7 @@ export function createGetKcContext<KcContextExtension extends { pageId: string }
|
||||
if (mockPageId !== undefined && realKcContext === undefined) {
|
||||
//TODO maybe trow if no mock fo custom page
|
||||
|
||||
console.log(
|
||||
[
|
||||
`%cKeycloakify: ${symToStr({ mockPageId })} set to ${mockPageId}.`,
|
||||
`If assets are missing make sure you have built your Keycloak theme at least once.`
|
||||
].join(" "),
|
||||
"background: red; color: yellow; font-size: medium"
|
||||
);
|
||||
console.log(`%cKeycloakify: ${symToStr({ mockPageId })} set to ${mockPageId}.`, "background: red; color: yellow; font-size: medium");
|
||||
|
||||
const kcContextDefaultMock = kcContextMocks.find(({ pageId }) => pageId === mockPageId);
|
||||
|
||||
@ -151,14 +144,14 @@ export function createGetKcContext<KcContextExtension extends { pageId: string }
|
||||
return { "kcContext": undefined as any };
|
||||
}
|
||||
|
||||
if (id<readonly string[]>(loginThemePageIds).indexOf(realKcContext.pageId) < 0 && !("login" in realKcContext)) {
|
||||
if (realKcContext.themeType !== "login") {
|
||||
return { "kcContext": undefined as any };
|
||||
}
|
||||
|
||||
{
|
||||
const { url } = realKcContext;
|
||||
|
||||
url.resourcesCommonPath = pathJoin(url.resourcesPath, pathBasename(mockTestingResourcesCommonPath));
|
||||
url.resourcesCommonPath = pathJoin(url.resourcesPath, pathBasename(resourcesCommonDirPathRelativeToPublicDir));
|
||||
}
|
||||
|
||||
return { "kcContext": realKcContext as any };
|
||||
|
@ -2,7 +2,9 @@ import type { DeepPartial } from "keycloakify/tools/DeepPartial";
|
||||
import type { ExtendKcContext } from "./getKcContextFromWindow";
|
||||
import { createGetKcContext } from "./createGetKcContext";
|
||||
|
||||
/** @deprecated: Use createGetKcContext instead */
|
||||
/** NOTE: We now recommend using createGetKcContext instead of this function to make storybook integration easier
|
||||
* See: https://github.com/keycloakify/keycloakify-starter/blob/main/src/keycloak-theme/account/kcContext.ts
|
||||
*/
|
||||
export function getKcContext<KcContextExtension extends { pageId: string } = never>(params?: {
|
||||
mockPageId?: ExtendKcContext<KcContextExtension>["pageId"];
|
||||
mockData?: readonly DeepPartial<ExtendKcContext<KcContextExtension>>[];
|
||||
|
@ -1,10 +1,12 @@
|
||||
import "minimal-polyfills/Object.fromEntries";
|
||||
import type { KcContext, Attribute } from "./KcContext";
|
||||
import { mockTestingResourcesCommonPath, mockTestingResourcesPath } from "keycloakify/bin/mockTestingResourcesPath";
|
||||
import { resourcesCommonDirPathRelativeToPublicDir, resourcesDirPathRelativeToPublicDir } from "keycloakify/bin/mockTestingResourcesPath";
|
||||
import { pathJoin } from "keycloakify/bin/tools/pathJoin";
|
||||
import { id } from "tsafe/id";
|
||||
import { assert, type Equals } from "tsafe/assert";
|
||||
import type { LoginThemePageId } from "keycloakify/bin/keycloakify/generateFtl";
|
||||
|
||||
const PUBLIC_URL = process.env["PUBLIC_URL"] ?? "/";
|
||||
const PUBLIC_URL = (typeof process !== "object" ? undefined : process.env?.["PUBLIC_URL"]) || "/";
|
||||
|
||||
const attributes: Attribute[] = [
|
||||
{
|
||||
@ -102,10 +104,12 @@ const attributesByName = Object.fromEntries(attributes.map(attribute => [attribu
|
||||
|
||||
export const kcContextCommonMock: KcContext.Common = {
|
||||
"keycloakifyVersion": "0.0.0",
|
||||
"themeType": "login",
|
||||
"themeName": "my-theme-name",
|
||||
"url": {
|
||||
"loginAction": "#",
|
||||
"resourcesPath": pathJoin(PUBLIC_URL, mockTestingResourcesPath),
|
||||
"resourcesCommonPath": pathJoin(PUBLIC_URL, mockTestingResourcesCommonPath),
|
||||
"resourcesPath": pathJoin(PUBLIC_URL, resourcesDirPathRelativeToPublicDir),
|
||||
"resourcesCommonPath": pathJoin(PUBLIC_URL, resourcesCommonDirPathRelativeToPublicDir),
|
||||
"loginRestartFlowUrl": "/auth/realms/myrealm/login-actions/restart?client_id=account&tab_id=HoAx28ja4xg",
|
||||
"loginUrl": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg"
|
||||
},
|
||||
@ -230,10 +234,6 @@ export const kcContextCommonMock: KcContext.Common = {
|
||||
"clientId": "myApp"
|
||||
},
|
||||
"scripts": [],
|
||||
"message": {
|
||||
"type": "success",
|
||||
"summary": "This is a test message"
|
||||
},
|
||||
"isAppInitiatedAction": false
|
||||
};
|
||||
|
||||
@ -243,7 +243,7 @@ const loginUrl = {
|
||||
"registrationUrl": "/auth/realms/myrealm/login-actions/registration?client_id=account&tab_id=HoAx28ja4xg"
|
||||
};
|
||||
|
||||
export const kcContextMocks: KcContext[] = [
|
||||
export const kcContextMocks = [
|
||||
id<KcContext.Login>({
|
||||
...kcContextCommonMock,
|
||||
"pageId": "login.ftl",
|
||||
@ -260,7 +260,7 @@ export const kcContextMocks: KcContext[] = [
|
||||
"social": {
|
||||
"displayInfo": true
|
||||
},
|
||||
"usernameEditDisabled": false,
|
||||
"usernameHidden": false,
|
||||
"login": {},
|
||||
"registrationDisabled": false
|
||||
}),
|
||||
@ -453,8 +453,8 @@ export const kcContextMocks: KcContext[] = [
|
||||
manualUrl: "#",
|
||||
totpSecret: "G4nsI8lQagRMUchH8jEG",
|
||||
otpCredentials: [],
|
||||
supportedApplications: ["FreeOTP", "Google Authenticator"],
|
||||
policy: {
|
||||
supportedApplications: ["FreeOTP", "Google Authenticator"],
|
||||
algorithm: "HmacSHA1",
|
||||
digits: 6,
|
||||
lookAheadWindow: 1,
|
||||
@ -519,5 +519,27 @@ export const kcContextMocks: KcContext[] = [
|
||||
}
|
||||
]
|
||||
}
|
||||
}),
|
||||
id<KcContext.SamlPostForm>({
|
||||
...kcContextCommonMock,
|
||||
pageId: "saml-post-form.ftl",
|
||||
"samlPost": {
|
||||
"url": ""
|
||||
}
|
||||
}),
|
||||
id<KcContext.LoginPageExpired>({
|
||||
...kcContextCommonMock,
|
||||
pageId: "login-page-expired.ftl"
|
||||
})
|
||||
];
|
||||
|
||||
{
|
||||
type Got = (typeof kcContextMocks)[number]["pageId"];
|
||||
type Expected = LoginThemePageId;
|
||||
|
||||
type OnlyInGot = Exclude<Got, Expected>;
|
||||
type OnlyInExpected = Exclude<Expected, Got>;
|
||||
|
||||
assert<Equals<OnlyInGot, never>>();
|
||||
assert<Equals<OnlyInExpected, never>>();
|
||||
}
|
||||
|
@ -25,16 +25,7 @@ export function useFormValidation(params: {
|
||||
passwordValidators?: Validators;
|
||||
i18n: I18n;
|
||||
}) {
|
||||
const {
|
||||
kcContext,
|
||||
passwordValidators = {
|
||||
"length": {
|
||||
"ignore.empty.value": true,
|
||||
"min": "4"
|
||||
}
|
||||
},
|
||||
i18n
|
||||
} = params;
|
||||
const { kcContext, passwordValidators = {}, i18n } = params;
|
||||
|
||||
const attributesWithPassword = useMemo(
|
||||
() =>
|
||||
@ -211,7 +202,7 @@ function useGetErrors(params: {
|
||||
const { value: defaultValue, validators } = attributes.find(attribute => attribute.name === name)!;
|
||||
|
||||
block: {
|
||||
if (defaultValue !== value) {
|
||||
if ((defaultValue ?? "") !== value) {
|
||||
break block;
|
||||
}
|
||||
|
||||
|
@ -3,6 +3,7 @@ import type { ClassKey } from "keycloakify/login/TemplateProps";
|
||||
|
||||
export const { useGetClassName } = createUseClassName<ClassKey>({
|
||||
"defaultClasses": {
|
||||
"kcBodyClass": undefined,
|
||||
"kcHtmlClass": "login-pf",
|
||||
"kcLoginClass": "login-pf-page",
|
||||
"kcContentWrapperClass": "row",
|
||||
|
@ -14,7 +14,7 @@ export default function Login(props: PageProps<Extract<KcContext, { pageId: "log
|
||||
classes
|
||||
});
|
||||
|
||||
const { social, realm, url, usernameEditDisabled, login, auth, registrationDisabled } = kcContext;
|
||||
const { social, realm, url, usernameHidden, login, auth, registrationDisabled } = kcContext;
|
||||
|
||||
const { msg, msgStr } = i18n;
|
||||
|
||||
@ -66,40 +66,37 @@ export default function Login(props: PageProps<Extract<KcContext, { pageId: "log
|
||||
{realm.password && (
|
||||
<form id="kc-form-login" onSubmit={onSubmit} action={url.loginAction} method="post">
|
||||
<div className={getClassName("kcFormGroupClass")}>
|
||||
{(() => {
|
||||
const label = !realm.loginWithEmailAllowed
|
||||
? "username"
|
||||
: realm.registrationEmailAsUsername
|
||||
? "email"
|
||||
: "usernameOrEmail";
|
||||
{!usernameHidden &&
|
||||
(() => {
|
||||
const label = !realm.loginWithEmailAllowed
|
||||
? "username"
|
||||
: realm.registrationEmailAsUsername
|
||||
? "email"
|
||||
: "usernameOrEmail";
|
||||
|
||||
const autoCompleteHelper: typeof label = label === "usernameOrEmail" ? "username" : label;
|
||||
const autoCompleteHelper: typeof label = label === "usernameOrEmail" ? "username" : label;
|
||||
|
||||
return (
|
||||
<>
|
||||
<label htmlFor={autoCompleteHelper} className={getClassName("kcLabelClass")}>
|
||||
{msg(label)}
|
||||
</label>
|
||||
<input
|
||||
tabIndex={1}
|
||||
id={autoCompleteHelper}
|
||||
className={getClassName("kcInputClass")}
|
||||
//NOTE: This is used by Google Chrome auto fill so we use it to tell
|
||||
//the browser how to pre fill the form but before submit we put it back
|
||||
//to username because it is what keycloak expects.
|
||||
name={autoCompleteHelper}
|
||||
defaultValue={login.username ?? ""}
|
||||
type="text"
|
||||
{...(usernameEditDisabled
|
||||
? { "disabled": true }
|
||||
: {
|
||||
"autoFocus": true,
|
||||
"autoComplete": "off"
|
||||
})}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
return (
|
||||
<>
|
||||
<label htmlFor={autoCompleteHelper} className={getClassName("kcLabelClass")}>
|
||||
{msg(label)}
|
||||
</label>
|
||||
<input
|
||||
tabIndex={1}
|
||||
id={autoCompleteHelper}
|
||||
className={getClassName("kcInputClass")}
|
||||
//NOTE: This is used by Google Chrome auto fill so we use it to tell
|
||||
//the browser how to pre fill the form but before submit we put it back
|
||||
//to username because it is what keycloak expects.
|
||||
name={autoCompleteHelper}
|
||||
defaultValue={login.username ?? ""}
|
||||
type="text"
|
||||
autoFocus={true}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
<div className={getClassName("kcFormGroupClass")}>
|
||||
<label htmlFor="password" className={getClassName("kcLabelClass")}>
|
||||
@ -116,7 +113,7 @@ export default function Login(props: PageProps<Extract<KcContext, { pageId: "log
|
||||
</div>
|
||||
<div className={clsx(getClassName("kcFormGroupClass"), getClassName("kcFormSettingClass"))}>
|
||||
<div id="kc-form-options">
|
||||
{realm.rememberMe && !usernameEditDisabled && (
|
||||
{realm.rememberMe && !usernameHidden && (
|
||||
<div className="checkbox">
|
||||
<label>
|
||||
<input
|
||||
|
@ -3,6 +3,7 @@ import type { PageProps } from "keycloakify/login/pages/PageProps";
|
||||
import { useGetClassName } from "keycloakify/login/lib/useGetClassName";
|
||||
import type { KcContext } from "../kcContext";
|
||||
import type { I18n } from "../i18n";
|
||||
import { MessageKey } from "keycloakify/login/i18n/i18n";
|
||||
|
||||
export default function LoginConfigTotp(props: PageProps<Extract<KcContext, { pageId: "login-config-totp.ftl" }>, I18n>) {
|
||||
const { kcContext, i18n, doUseDefaultCss, Template, classes } = props;
|
||||
@ -16,7 +17,7 @@ export default function LoginConfigTotp(props: PageProps<Extract<KcContext, { pa
|
||||
|
||||
const { msg, msgStr } = i18n;
|
||||
|
||||
const algToKeyUriAlg: Record<KcContext.LoginConfigTotp["totp"]["policy"]["algorithm"], string> = {
|
||||
const algToKeyUriAlg: Record<(typeof kcContext)["totp"]["policy"]["algorithm"], string> = {
|
||||
"HmacSHA1": "SHA1",
|
||||
"HmacSHA256": "SHA256",
|
||||
"HmacSHA512": "SHA512"
|
||||
@ -30,8 +31,8 @@ export default function LoginConfigTotp(props: PageProps<Extract<KcContext, { pa
|
||||
<p>{msg("loginTotpStep1")}</p>
|
||||
|
||||
<ul id="kc-totp-supported-apps">
|
||||
{totp.policy.supportedApplications.map(app => (
|
||||
<li>{app}</li>
|
||||
{totp.supportedApplications.map(app => (
|
||||
<li>{msgStr(app as MessageKey, app)}</li>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
@ -169,7 +170,7 @@ export default function LoginConfigTotp(props: PageProps<Extract<KcContext, { pa
|
||||
name="cancel-aia"
|
||||
value="true"
|
||||
>
|
||||
${msg("doCancel")}
|
||||
{msg("doCancel")}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
|
@ -1,6 +1,5 @@
|
||||
import { useEffect } from "react";
|
||||
import { headInsert } from "keycloakify/tools/headInsert";
|
||||
import { pathJoin } from "keycloakify/bin/tools/pathJoin";
|
||||
import { clsx } from "keycloakify/tools/clsx";
|
||||
import type { PageProps } from "keycloakify/login/pages/PageProps";
|
||||
import { useGetClassName } from "keycloakify/login/lib/useGetClassName";
|
||||
@ -22,17 +21,24 @@ export default function LoginOtp(props: PageProps<Extract<KcContext, { pageId: "
|
||||
useEffect(() => {
|
||||
let isCleanedUp = false;
|
||||
|
||||
headInsert({
|
||||
const { prLoaded, remove } = headInsert({
|
||||
"type": "javascript",
|
||||
"src": pathJoin(kcContext.url.resourcesCommonPath, "node_modules/jquery/dist/jquery.min.js")
|
||||
}).then(() => {
|
||||
if (isCleanedUp) return;
|
||||
"src": `${kcContext.url.resourcesCommonPath}/node_modules/jquery/dist/jquery.min.js`
|
||||
});
|
||||
|
||||
(async () => {
|
||||
await prLoaded;
|
||||
|
||||
if (isCleanedUp) {
|
||||
return;
|
||||
}
|
||||
|
||||
evaluateInlineScript();
|
||||
});
|
||||
})();
|
||||
|
||||
return () => {
|
||||
isCleanedUp = true;
|
||||
remove();
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
@ -123,7 +123,7 @@ export default function LoginUpdatePassword(props: PageProps<Extract<KcContext,
|
||||
getClassName("kcButtonLargeClass")
|
||||
)}
|
||||
type="submit"
|
||||
defaultValue={msgStr("doSubmit")}
|
||||
value={msgStr("doSubmit")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
@ -11,7 +11,7 @@ export default function LoginVerifyEmail(props: PageProps<Extract<KcContext, { p
|
||||
|
||||
return (
|
||||
<Template {...{ kcContext, i18n, doUseDefaultCss, classes }} displayMessage={false} headerNode={msg("emailVerifyTitle")}>
|
||||
<p className="instruction">{msg("emailVerifyInstruction1", user?.email)}</p>
|
||||
<p className="instruction">{msg("emailVerifyInstruction1", user?.email ?? "")}</p>
|
||||
<p className="instruction">
|
||||
{msg("emailVerifyInstruction2")}
|
||||
<br />
|
||||
|
@ -14,11 +14,13 @@ export default function RegisterUserProfile(props: PageProps<Extract<KcContext,
|
||||
classes
|
||||
});
|
||||
|
||||
const { url, messagesPerField, recaptchaRequired, recaptchaSiteKey } = kcContext;
|
||||
const { url, messagesPerField, recaptchaRequired, recaptchaSiteKey, realm } = kcContext;
|
||||
|
||||
realm.registrationEmailAsUsername;
|
||||
|
||||
const { msg, msgStr } = i18n;
|
||||
|
||||
const [isFomSubmittable, setIsFomSubmittable] = useState(false);
|
||||
const [isFormSubmittable, setIsFormSubmittable] = useState(false);
|
||||
|
||||
return (
|
||||
<Template
|
||||
@ -30,7 +32,7 @@ export default function RegisterUserProfile(props: PageProps<Extract<KcContext,
|
||||
<form id="kc-register-form" className={getClassName("kcFormClass")} action={url.registrationAction} method="post">
|
||||
<UserProfileFormFields
|
||||
kcContext={kcContext}
|
||||
onIsFormSubmittableValueChange={setIsFomSubmittable}
|
||||
onIsFormSubmittableValueChange={setIsFormSubmittable}
|
||||
i18n={i18n}
|
||||
getClassName={getClassName}
|
||||
/>
|
||||
@ -60,7 +62,7 @@ export default function RegisterUserProfile(props: PageProps<Extract<KcContext,
|
||||
)}
|
||||
type="submit"
|
||||
value={msgStr("doRegister")}
|
||||
disabled={!isFomSubmittable}
|
||||
disabled={!isFormSubmittable}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
42
src/login/pages/SamlPostForm.tsx
Normal file
42
src/login/pages/SamlPostForm.tsx
Normal file
@ -0,0 +1,42 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { PageProps } from "keycloakify/login/pages/PageProps";
|
||||
import type { KcContext } from "../kcContext";
|
||||
import type { I18n } from "../i18n";
|
||||
|
||||
export default function SamlPostForm(props: PageProps<Extract<KcContext, { pageId: "saml-post-form.ftl" }>, I18n>) {
|
||||
const { kcContext, i18n, doUseDefaultCss, Template, classes } = props;
|
||||
|
||||
const { msgStr, msg } = i18n;
|
||||
|
||||
const { samlPost } = kcContext;
|
||||
|
||||
const [htmlFormElement, setHtmlFormElement] = useState<HTMLFormElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (htmlFormElement === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Storybook
|
||||
if (samlPost.url === "") {
|
||||
alert("In a real Keycloak the user would be redirected immediately");
|
||||
return;
|
||||
}
|
||||
|
||||
htmlFormElement.submit();
|
||||
}, [htmlFormElement]);
|
||||
return (
|
||||
<Template {...{ kcContext, i18n, doUseDefaultCss, classes }} displayMessage={false} headerNode={msg("saml.post-form.title")}>
|
||||
<p>{msg("saml.post-form.message")}</p>
|
||||
<form name="saml-post-binding" method="post" action={samlPost.url} ref={setHtmlFormElement}>
|
||||
{samlPost.SAMLRequest && <input type="hidden" name="SAMLRequest" value={samlPost.SAMLRequest} />}
|
||||
{samlPost.SAMLResponse && <input type="hidden" name="SAMLResponse" value={samlPost.SAMLResponse} />}
|
||||
{samlPost.relayState && <input type="hidden" name="RelayState" value={samlPost.relayState} />}
|
||||
<noscript>
|
||||
<p>{msg("saml.post-form.js-disabled")}</p>
|
||||
<input type="submit" value={msgStr("doContinue")} />
|
||||
</noscript>
|
||||
</form>
|
||||
</Template>
|
||||
);
|
||||
}
|
@ -7,6 +7,9 @@ import type { PageProps } from "keycloakify/login/pages/PageProps";
|
||||
import { useGetClassName } from "keycloakify/login/lib/useGetClassName";
|
||||
import type { KcContext } from "../kcContext";
|
||||
import type { I18n } from "../i18n";
|
||||
import { assert } from "tsafe/assert";
|
||||
import { is } from "tsafe/is";
|
||||
import { typeGuard } from "tsafe/typeGuard";
|
||||
|
||||
export default function WebauthnAuthenticate(props: PageProps<Extract<KcContext, { pageId: "webauthn-authenticate.ftl" }>, I18n>) {
|
||||
const { kcContext, i18n, doUseDefaultCss, Template, classes } = props;
|
||||
@ -21,10 +24,24 @@ export default function WebauthnAuthenticate(props: PageProps<Extract<KcContext,
|
||||
const createTimeout = Number(kcContext.createTimeout);
|
||||
const isUserIdentified = kcContext.isUserIdentified == "true";
|
||||
|
||||
const formElementRef = useRef<HTMLFormElement>(null);
|
||||
|
||||
const webAuthnAuthenticate = useConstCallback(async () => {
|
||||
if (!isUserIdentified) {
|
||||
return;
|
||||
}
|
||||
|
||||
const submitForm = async (): Promise<void> => {
|
||||
const formElement = formElementRef.current;
|
||||
|
||||
if (formElement === null) {
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
return submitForm();
|
||||
}
|
||||
|
||||
formElement.submit();
|
||||
};
|
||||
|
||||
const allowCredentials = authenticators.authenticators.map(
|
||||
authenticator =>
|
||||
({
|
||||
@ -57,30 +74,36 @@ export default function WebauthnAuthenticate(props: PageProps<Extract<KcContext,
|
||||
}
|
||||
|
||||
try {
|
||||
const resultRaw = await navigator.credentials.get({ publicKey });
|
||||
if (!resultRaw || resultRaw.type != "public-key") return;
|
||||
const result = resultRaw as PublicKeyCredential;
|
||||
if (!("authenticatorData" in result.response)) return;
|
||||
const response = result.response as AuthenticatorAssertionResponse;
|
||||
const result = await navigator.credentials.get({ publicKey });
|
||||
if (!result || result.type != "public-key") {
|
||||
return;
|
||||
}
|
||||
assert(is<PublicKeyCredential>(result));
|
||||
if (!("authenticatorData" in result.response)) {
|
||||
return;
|
||||
}
|
||||
const response = result.response;
|
||||
|
||||
const clientDataJSON = response.clientDataJSON;
|
||||
|
||||
assert(
|
||||
typeGuard<AuthenticatorAssertionResponse>(response, "signature" in response && response.authenticatorData instanceof ArrayBuffer),
|
||||
"response not an AuthenticatorAssertionResponse"
|
||||
);
|
||||
|
||||
const authenticatorData = response.authenticatorData;
|
||||
const signature = response.signature;
|
||||
|
||||
setClientDataJSON(base64url.stringify(new Uint8Array(clientDataJSON), { pad: false }));
|
||||
setAuthenticatorData(base64url.stringify(new Uint8Array(authenticatorData), { pad: false }));
|
||||
setSignature(base64url.stringify(new Uint8Array(signature), { pad: false }));
|
||||
setClientDataJSON(base64url.stringify(new Uint8Array(clientDataJSON), { "pad": false }));
|
||||
setAuthenticatorData(base64url.stringify(new Uint8Array(authenticatorData), { "pad": false }));
|
||||
setSignature(base64url.stringify(new Uint8Array(signature), { "pad": false }));
|
||||
setCredentialId(result.id);
|
||||
setUserHandle(base64url.stringify(new Uint8Array(response.userHandle!), { pad: false }));
|
||||
submitForm();
|
||||
setUserHandle(base64url.stringify(new Uint8Array(response.userHandle!), { "pad": false }));
|
||||
} catch (err) {
|
||||
setError(String(err));
|
||||
submitForm();
|
||||
}
|
||||
});
|
||||
|
||||
const webAuthForm = useRef<HTMLFormElement>(null);
|
||||
const submitForm = useConstCallback(() => {
|
||||
webAuthForm.current!.submit();
|
||||
submitForm();
|
||||
});
|
||||
|
||||
const [clientDataJSON, setClientDataJSON] = useState("");
|
||||
@ -93,7 +116,7 @@ export default function WebauthnAuthenticate(props: PageProps<Extract<KcContext,
|
||||
return (
|
||||
<Template {...{ kcContext, i18n, doUseDefaultCss, classes }} headerNode={msg("webauthn-login-title")}>
|
||||
<div id="kc-form-webauthn" className={getClassName("kcFormClass")}>
|
||||
<form id="webauth" action={url.loginAction} ref={webAuthForm} method="post">
|
||||
<form id="webauth" action={url.loginAction} ref={formElementRef} method="post">
|
||||
<input type="hidden" id="clientDataJSON" name="clientDataJSON" value={clientDataJSON} />
|
||||
<input type="hidden" id="authenticatorData" name="authenticatorData" value={authenticatorData} />
|
||||
<input type="hidden" id="signature" name="signature" value={signature} />
|
||||
|
@ -17,7 +17,7 @@ export type UserProfileFormFieldsProps = {
|
||||
export function UserProfileFormFields(props: UserProfileFormFieldsProps) {
|
||||
const { kcContext, onIsFormSubmittableValueChange, i18n, getClassName, BeforeField, AfterField } = props;
|
||||
|
||||
const { advancedMsg } = i18n;
|
||||
const { advancedMsg, msg } = i18n;
|
||||
|
||||
const {
|
||||
formValidationState: { fieldStateByAttributeName, isFormSubmittable },
|
||||
@ -98,11 +98,16 @@ export function UserProfileFormFields(props: UserProfileFormFieldsProps) {
|
||||
}
|
||||
value={value}
|
||||
>
|
||||
{options.options.map(option => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
<>
|
||||
<option value="" selected disabled hidden>
|
||||
{msg("selectAnOption")}
|
||||
</option>
|
||||
))}
|
||||
{options.options.map(option => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</>
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
@ -12,7 +12,7 @@ export function headInsert(
|
||||
type: "javascript";
|
||||
src: string;
|
||||
}
|
||||
) {
|
||||
): { remove: () => void; prLoaded: Promise<void> } {
|
||||
const htmlElement = document.createElement(
|
||||
(() => {
|
||||
switch (params.type) {
|
||||
@ -66,5 +66,8 @@ export function headInsert(
|
||||
})()
|
||||
](htmlElement);
|
||||
|
||||
return dLoaded.pr;
|
||||
return {
|
||||
"prLoaded": dLoaded.pr,
|
||||
"remove": () => htmlElement.remove()
|
||||
};
|
||||
}
|
||||
|
27
stories/account/KcApp.tsx
Normal file
27
stories/account/KcApp.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
import React, { lazy, Suspense } from "react";
|
||||
import Fallback from "../../dist/account";
|
||||
import type { KcContext } from "./kcContext";
|
||||
import { useI18n } from "./i18n";
|
||||
|
||||
const DefaultTemplate = lazy(() => import("../../dist/account/Template"));
|
||||
|
||||
export default function KcApp(props: { kcContext: KcContext }) {
|
||||
const { kcContext } = props;
|
||||
|
||||
const i18n = useI18n({ kcContext });
|
||||
|
||||
if (i18n === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense>
|
||||
{(() => {
|
||||
switch (kcContext.pageId) {
|
||||
default:
|
||||
return <Fallback {...{ kcContext, i18n }} Template={DefaultTemplate} doUseDefaultCss={true} />;
|
||||
}
|
||||
})()}
|
||||
</Suspense>
|
||||
);
|
||||
}
|
19
stories/account/createPageStory.tsx
Normal file
19
stories/account/createPageStory.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
import React from "react";
|
||||
import { getKcContext, type KcContext } from "./kcContext";
|
||||
import KcApp from "./KcApp";
|
||||
import type { DeepPartial } from "../../dist/tools/DeepPartial";
|
||||
|
||||
export function createPageStory<PageId extends KcContext["pageId"]>(params: { pageId: PageId }) {
|
||||
const { pageId } = params;
|
||||
|
||||
function PageStory(params: { kcContext?: DeepPartial<Extract<KcContext, { pageId: PageId }>> }) {
|
||||
const { kcContext } = getKcContext({
|
||||
mockPageId: pageId,
|
||||
storyPartialKcContext: params.kcContext
|
||||
});
|
||||
|
||||
return <KcApp kcContext={kcContext} />;
|
||||
}
|
||||
|
||||
return { PageStory };
|
||||
}
|
5
stories/account/i18n.ts
Normal file
5
stories/account/i18n.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { createUseI18n } from "../../dist/account";
|
||||
|
||||
export const { useI18n } = createUseI18n({});
|
||||
|
||||
export type I18n = NonNullable<ReturnType<typeof useI18n>>;
|
7
stories/account/kcContext.ts
Normal file
7
stories/account/kcContext.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { createGetKcContext } from "../../dist/account";
|
||||
|
||||
export const { getKcContext } = createGetKcContext();
|
||||
|
||||
const { kcContext } = getKcContext();
|
||||
|
||||
export type KcContext = NonNullable<typeof kcContext>;
|
24
stories/account/pages/Account.stories.tsx
Normal file
24
stories/account/pages/Account.stories.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import React from "react";
|
||||
import type { ComponentMeta } from "@storybook/react";
|
||||
import { createPageStory } from "../createPageStory";
|
||||
|
||||
const pageId = "account.ftl";
|
||||
|
||||
const { PageStory } = createPageStory({ pageId });
|
||||
|
||||
const meta: ComponentMeta<any> = {
|
||||
title: `account/${pageId}`,
|
||||
component: PageStory,
|
||||
parameters: {
|
||||
viewMode: "story",
|
||||
previewTabs: {
|
||||
"storybook/docs/panel": {
|
||||
hidden: true
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
export const Default = () => <PageStory />;
|
32
stories/account/pages/Pasword.stories.tsx
Normal file
32
stories/account/pages/Pasword.stories.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
import React from "react";
|
||||
import type { ComponentMeta } from "@storybook/react";
|
||||
import { createPageStory } from "../createPageStory";
|
||||
|
||||
const pageId = "password.ftl";
|
||||
|
||||
const { PageStory } = createPageStory({ pageId });
|
||||
|
||||
const meta: ComponentMeta<any> = {
|
||||
title: `account/${pageId}`,
|
||||
component: PageStory,
|
||||
parameters: {
|
||||
viewMode: "story",
|
||||
previewTabs: {
|
||||
"storybook/docs/panel": {
|
||||
hidden: true
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
export const Default = () => <PageStory />;
|
||||
|
||||
export const WithMessage = () => (
|
||||
<PageStory
|
||||
kcContext={{
|
||||
message: { type: "success", summary: "This is a test message" }
|
||||
}}
|
||||
/>
|
||||
);
|
9
stories/global.d.ts
vendored
Normal file
9
stories/global.d.ts
vendored
Normal file
@ -0,0 +1,9 @@
|
||||
declare module "*.png" {
|
||||
const _default: string;
|
||||
export default _default;
|
||||
}
|
||||
|
||||
declare module "*.md" {
|
||||
const _default: string;
|
||||
export default _default;
|
||||
}
|
61
stories/intro/KeycloakifyRotatingLogo.tsx
Normal file
61
stories/intro/KeycloakifyRotatingLogo.tsx
Normal file
@ -0,0 +1,61 @@
|
||||
import React from "react";
|
||||
import { memo, useState } from "react";
|
||||
import { useConstCallback } from "powerhooks";
|
||||
import { keyframes } from "tss-react";
|
||||
import keycloakifyLogoHeroMovingPngUrl from "./keycloakify-logo-hero-moving.png";
|
||||
import keycloakifyLogoHeroStillPngUrl from "./keycloakify-logo-hero-still.png";
|
||||
import { makeStyles } from "./tss";
|
||||
|
||||
export type Props = {
|
||||
style?: React.CSSProperties;
|
||||
id?: string;
|
||||
onLoad?: () => void;
|
||||
};
|
||||
|
||||
export const KeycloakifyRotatingLogo = memo((props: Props) => {
|
||||
const { id, style, onLoad: onLoadProp } = props;
|
||||
|
||||
const [isImageLoaded, setIsImageLoaded] = useState(false);
|
||||
|
||||
const onLoad = useConstCallback(() => {
|
||||
setIsImageLoaded(true);
|
||||
onLoadProp?.();
|
||||
});
|
||||
|
||||
const { classes } = useStyles({
|
||||
isImageLoaded
|
||||
});
|
||||
return (
|
||||
<div id={id} className={classes.root} style={style}>
|
||||
<img className={classes.rotatingImg} onLoad={onLoad} src={keycloakifyLogoHeroMovingPngUrl} alt={"Rotating react logo"} />
|
||||
<img className={classes.stillImg} src={keycloakifyLogoHeroStillPngUrl} alt={"keyhole"} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const useStyles = makeStyles<{ isImageLoaded: boolean }>({
|
||||
"name": { KeycloakifyRotatingLogo }
|
||||
})((_theme, { isImageLoaded }) => ({
|
||||
"root": {
|
||||
"position": "relative"
|
||||
},
|
||||
"rotatingImg": {
|
||||
"animation": `${keyframes({
|
||||
"from": {
|
||||
"transform": "rotate(0deg)"
|
||||
},
|
||||
"to": {
|
||||
"transform": "rotate(360deg)"
|
||||
}
|
||||
})} infinite 20s linear`,
|
||||
"width": isImageLoaded ? "100%" : undefined,
|
||||
"height": isImageLoaded ? "auto" : undefined
|
||||
},
|
||||
"stillImg": {
|
||||
"position": "absolute",
|
||||
"top": "0",
|
||||
"left": "0",
|
||||
"width": isImageLoaded ? "100%" : undefined,
|
||||
"height": isImageLoaded ? "auto" : undefined
|
||||
}
|
||||
}));
|
@ -1,6 +1,5 @@
|
||||
import { Meta } from "@storybook/addon-docs";
|
||||
import { useDarkMode } from "storybook-dark-mode";
|
||||
import "./assets/fonts/WorkSans/font.css";
|
||||
import { KeycloakifyRotatingLogo } from "./KeycloakifyRotatingLogo";
|
||||
|
||||
<Meta
|
||||
title="Introduction"
|
||||
@ -16,13 +15,15 @@ import "./assets/fonts/WorkSans/font.css";
|
||||
/>
|
||||
|
||||
<div style={{ "margin": "0 auto", "maxWidth": "700px", "textAlign": "center" }}>
|
||||
<img src="preview.png" />
|
||||
<div style={{ "display": "flex", "justifyContent": "center" }}>
|
||||
<KeycloakifyRotatingLogo style={{ "width": 400 }} />
|
||||
</div>
|
||||
<h1><a href="#">Keycloakify </a> Storybook</h1>
|
||||
|
||||
<p>
|
||||
This website showcases all the Keycloak user-facing pages that can be customized using Keycloakify.
|
||||
The storybook serves as a comprehensive reference to help you determine which pages you would like to personalize.
|
||||
Keep in mind that customizing the <code>Template</code> component alone will already cover 90% of your customization needs.
|
||||
Keep in mind that customizing the <a href="https://github.com/keycloakify/keycloakify-starter/blob/main/src/keycloak-theme/login/Template.tsx"><code>Template</code></a> component alone will already cover 90% of your customization needs.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
@ -31,4 +32,4 @@ Simply refer to <a href="https://docs.keycloakify.dev/limitations#i-have-establi
|
||||
</p>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
BIN
stories/intro/keycloakify-logo-hero-moving.png
Normal file
BIN
stories/intro/keycloakify-logo-hero-moving.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 95 KiB |
BIN
stories/intro/keycloakify-logo-hero-still.png
Normal file
BIN
stories/intro/keycloakify-logo-hero-still.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 8.9 KiB |
5
stories/intro/tss.ts
Normal file
5
stories/intro/tss.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { createMakeAndWithStyles } from "tss-react";
|
||||
|
||||
export const { makeStyles, useStyles } = createMakeAndWithStyles({
|
||||
"useTheme": () => ({})
|
||||
});
|
50
stories/login/KcApp.tsx
Normal file
50
stories/login/KcApp.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
import React, { lazy, Suspense } from "react";
|
||||
import Fallback from "../../dist/login";
|
||||
import type { KcContext } from "./kcContext";
|
||||
import { useI18n } from "./i18n";
|
||||
import { useDownloadTerms } from "../../dist/login/lib/useDownloadTerms";
|
||||
import tos_en_url from "./tos_en.md";
|
||||
import tos_fr_url from "./tos_fr.md";
|
||||
|
||||
const DefaultTemplate = lazy(() => import("../../dist/login/Template"));
|
||||
|
||||
export default function KcApp(props: { kcContext: KcContext }) {
|
||||
const { kcContext } = props;
|
||||
|
||||
const i18n = useI18n({ kcContext });
|
||||
|
||||
useDownloadTerms({
|
||||
"kcContext": kcContext as any,
|
||||
"downloadTermMarkdown": async ({ currentLanguageTag }) => {
|
||||
const resource = (() => {
|
||||
switch (currentLanguageTag) {
|
||||
case "fr":
|
||||
return tos_fr_url;
|
||||
default:
|
||||
return tos_en_url;
|
||||
}
|
||||
})();
|
||||
|
||||
// webpack5 (used via storybook) loads markdown as string, not url
|
||||
if (resource.includes("\n")) return resource;
|
||||
|
||||
const response = await fetch(resource);
|
||||
return response.text();
|
||||
}
|
||||
});
|
||||
|
||||
if (i18n === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense>
|
||||
{(() => {
|
||||
switch (kcContext.pageId) {
|
||||
default:
|
||||
return <Fallback {...{ kcContext, i18n }} Template={DefaultTemplate} doUseDefaultCss={true} />;
|
||||
}
|
||||
})()}
|
||||
</Suspense>
|
||||
);
|
||||
}
|
19
stories/login/createPageStory.tsx
Normal file
19
stories/login/createPageStory.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
import React from "react";
|
||||
import { getKcContext, type KcContext } from "./kcContext";
|
||||
import KcApp from "./KcApp";
|
||||
import type { DeepPartial } from "../../dist/tools/DeepPartial";
|
||||
|
||||
export function createPageStory<PageId extends KcContext["pageId"]>(params: { pageId: PageId }) {
|
||||
const { pageId } = params;
|
||||
|
||||
function PageStory(params: { kcContext?: DeepPartial<Extract<KcContext, { pageId: PageId }>> }) {
|
||||
const { kcContext } = getKcContext({
|
||||
mockPageId: pageId,
|
||||
storyPartialKcContext: params.kcContext
|
||||
});
|
||||
|
||||
return <KcApp kcContext={kcContext} />;
|
||||
}
|
||||
|
||||
return { PageStory };
|
||||
}
|
5
stories/login/i18n.ts
Normal file
5
stories/login/i18n.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { createUseI18n } from "../../dist/login";
|
||||
|
||||
export const { useI18n } = createUseI18n({});
|
||||
|
||||
export type I18n = NonNullable<ReturnType<typeof useI18n>>;
|
7
stories/login/kcContext.ts
Normal file
7
stories/login/kcContext.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { createGetKcContext } from "../../dist/login";
|
||||
|
||||
export const { getKcContext } = createGetKcContext();
|
||||
|
||||
const { kcContext } = getKcContext();
|
||||
|
||||
export type KcContext = NonNullable<typeof kcContext>;
|
32
stories/login/pages/Error.stories.tsx
Normal file
32
stories/login/pages/Error.stories.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
import React from "react";
|
||||
import type { ComponentMeta } from "@storybook/react";
|
||||
import { createPageStory } from "../createPageStory";
|
||||
|
||||
const pageId = "error.ftl";
|
||||
|
||||
const { PageStory } = createPageStory({ pageId });
|
||||
|
||||
const meta: ComponentMeta<any> = {
|
||||
title: `login/${pageId}`,
|
||||
component: PageStory,
|
||||
parameters: {
|
||||
viewMode: "story",
|
||||
previewTabs: {
|
||||
"storybook/docs/panel": {
|
||||
hidden: true
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
export const Default = () => <PageStory />;
|
||||
|
||||
export const WithAnotherMessage = () => (
|
||||
<PageStory
|
||||
kcContext={{
|
||||
message: { summary: "With another error message" }
|
||||
}}
|
||||
/>
|
||||
);
|
24
stories/login/pages/IdpReviewUserProfile.stories.tsx
Normal file
24
stories/login/pages/IdpReviewUserProfile.stories.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import React from "react";
|
||||
import type { ComponentMeta } from "@storybook/react";
|
||||
import { createPageStory } from "../createPageStory";
|
||||
|
||||
const pageId = "idp-review-user-profile.ftl";
|
||||
|
||||
const { PageStory } = createPageStory({ pageId });
|
||||
|
||||
const meta: ComponentMeta<any> = {
|
||||
title: `login/${pageId}`,
|
||||
component: PageStory,
|
||||
parameters: {
|
||||
viewMode: "story",
|
||||
previewTabs: {
|
||||
"storybook/docs/panel": {
|
||||
"hidden": true
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
export const Default = () => <PageStory />;
|
24
stories/login/pages/Info.stories.tsx
Normal file
24
stories/login/pages/Info.stories.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import React from "react";
|
||||
import type { ComponentMeta } from "@storybook/react";
|
||||
import { createPageStory } from "../createPageStory";
|
||||
|
||||
const pageId = "info.ftl";
|
||||
|
||||
const { PageStory } = createPageStory({ pageId });
|
||||
|
||||
const meta: ComponentMeta<any> = {
|
||||
title: `login/${pageId}`,
|
||||
component: PageStory,
|
||||
parameters: {
|
||||
viewMode: "story",
|
||||
previewTabs: {
|
||||
"storybook/docs/panel": {
|
||||
"hidden": true
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
export const Default = () => <PageStory />;
|
109
stories/login/pages/Login.stories.tsx
Normal file
109
stories/login/pages/Login.stories.tsx
Normal file
@ -0,0 +1,109 @@
|
||||
import React from "react";
|
||||
import type { ComponentMeta } from "@storybook/react";
|
||||
import { createPageStory } from "../createPageStory";
|
||||
|
||||
const pageId = "login.ftl";
|
||||
|
||||
const { PageStory } = createPageStory({ pageId });
|
||||
|
||||
const meta: ComponentMeta<any> = {
|
||||
title: `login/${pageId}`,
|
||||
component: PageStory,
|
||||
parameters: {
|
||||
viewMode: "story",
|
||||
previewTabs: {
|
||||
"storybook/docs/panel": {
|
||||
"hidden": true
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
export const Default = () => <PageStory />;
|
||||
|
||||
export const WithoutPasswordField = () => (
|
||||
<PageStory
|
||||
kcContext={{
|
||||
realm: { password: false }
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
export const WithoutRegistration = () => (
|
||||
<PageStory
|
||||
kcContext={{
|
||||
realm: { registrationAllowed: false }
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
export const WithoutRememberMe = () => (
|
||||
<PageStory
|
||||
kcContext={{
|
||||
realm: { rememberMe: false }
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
export const WithoutPasswordReset = () => (
|
||||
<PageStory
|
||||
kcContext={{
|
||||
realm: { resetPasswordAllowed: false }
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
export const WithEmailAsUsername = () => (
|
||||
<PageStory
|
||||
kcContext={{
|
||||
realm: { loginWithEmailAllowed: false }
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
export const WithPresetUsername = () => (
|
||||
<PageStory
|
||||
kcContext={{
|
||||
login: { username: "max.mustermann@mail.com" }
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
export const WithImmutablePresetUsername = () => (
|
||||
<PageStory
|
||||
kcContext={{
|
||||
auth: {
|
||||
attemptedUsername: "max.mustermann@mail.com",
|
||||
showUsername: true
|
||||
},
|
||||
usernameHidden: true,
|
||||
message: { type: "info", summary: "Please re-authenticate to continue" }
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
export const WithSocialProviders = () => (
|
||||
<PageStory
|
||||
kcContext={{
|
||||
social: {
|
||||
displayInfo: true,
|
||||
providers: [
|
||||
{ loginUrl: "google", alias: "google", providerId: "google", displayName: "Google" },
|
||||
{ loginUrl: "microsoft", alias: "microsoft", providerId: "microsoft", displayName: "Microsoft" },
|
||||
{ loginUrl: "facebook", alias: "facebook", providerId: "facebook", displayName: "Facebook" },
|
||||
{ loginUrl: "instagram", alias: "instagram", providerId: "instagram", displayName: "Instagram" },
|
||||
{ loginUrl: "twitter", alias: "twitter", providerId: "twitter", displayName: "Twitter" },
|
||||
{ loginUrl: "linkedin", alias: "linkedin", providerId: "linkedin", displayName: "LinkedIn" },
|
||||
{ loginUrl: "stackoverflow", alias: "stackoverflow", providerId: "stackoverflow", displayName: "Stackoverflow" },
|
||||
{ loginUrl: "github", alias: "github", providerId: "github", displayName: "Github" },
|
||||
{ loginUrl: "gitlab", alias: "gitlab", providerId: "gitlab", displayName: "Gitlab" },
|
||||
{ loginUrl: "bitbucket", alias: "bitbucket", providerId: "bitbucket", displayName: "Bitbucket" },
|
||||
{ loginUrl: "paypal", alias: "paypal", providerId: "paypal", displayName: "PayPal" },
|
||||
{ loginUrl: "openshift", alias: "openshift", providerId: "openshift", displayName: "OpenShift" }
|
||||
]
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
24
stories/login/pages/LoginConfigTotp.stories.tsx
Normal file
24
stories/login/pages/LoginConfigTotp.stories.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import React from "react";
|
||||
import type { ComponentMeta } from "@storybook/react";
|
||||
import { createPageStory } from "../createPageStory";
|
||||
|
||||
const pageId = "login-config-totp.ftl";
|
||||
|
||||
const { PageStory } = createPageStory({ pageId });
|
||||
|
||||
const meta: ComponentMeta<any> = {
|
||||
title: `login/${pageId}`,
|
||||
component: PageStory,
|
||||
parameters: {
|
||||
viewMode: "story",
|
||||
previewTabs: {
|
||||
"storybook/docs/panel": {
|
||||
"hidden": true
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
export const Default = () => <PageStory />;
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user