Compare commits

..

11 Commits
main ... v8

Author SHA1 Message Date
Joseph Garrone
e2cccb4df1 Bump version 2024-09-30 11:40:19 +02:00
Joseph Garrone
cde8f11c60 Fix allegated vulnerability 2024-09-30 11:40:07 +02:00
Joseph Garrone
4272a860a2 Use tag v8 when releasing from this branch 2024-08-16 08:40:27 +02:00
Joseph Garrone
3186d33ad7 Release patch 2024-08-13 14:32:09 +02:00
Joseph Garrone
b7fb8f98ae #607 Release patch for v8 2024-08-13 14:31:52 +02:00
Joseph Garrone
6e666b6ee2 Bump version 2024-02-27 23:18:33 +01:00
Joseph Garrone
4c66c9877a #513 2024-02-27 23:18:19 +01:00
Joseph Garrone
e8fce5f796 Fix ftl script bug and definitively address #512 and #432 2024-02-27 06:54:10 +01:00
Joseph Garrone
481131d58b update ci 2024-02-17 03:56:44 +01:00
Joseph Garrone
0a4e71e149 Bump version 2024-02-17 03:54:28 +01:00
Joseph Garrone
96f5139421 Follow up on #406 2024-02-17 03:54:11 +01:00
420 changed files with 13551 additions and 54968 deletions

View File

@ -186,183 +186,6 @@
"contributions": [
"code"
]
},
{
"login": "xgp",
"name": "Garth",
"avatar_url": "https://avatars.githubusercontent.com/u/244253?v=4",
"profile": "https://github.com/xgp",
"contributions": [
"code"
]
},
{
"login": "BlackVoid",
"name": "Felix Gustavsson",
"avatar_url": "https://avatars.githubusercontent.com/u/673720?v=4",
"profile": "https://github.com/BlackVoid",
"contributions": [
"code"
]
},
{
"login": "msiemens",
"name": "Markus Siemens",
"avatar_url": "https://avatars.githubusercontent.com/u/1873922?v=4",
"profile": "https://m-siemens.de/",
"contributions": [
"code"
]
},
{
"login": "law108000",
"name": "Rlok",
"avatar_url": "https://avatars.githubusercontent.com/u/8112024?v=4",
"profile": "https://github.com/law108000",
"contributions": [
"code"
]
},
{
"login": "Moulyy",
"name": "Moulyy",
"avatar_url": "https://avatars.githubusercontent.com/u/115405804?v=4",
"profile": "https://github.com/Moulyy",
"contributions": [
"code"
]
},
{
"login": "madmadson",
"name": "Tobias Matt",
"avatar_url": "https://avatars.githubusercontent.com/u/798831?v=4",
"profile": "https://github.com/madmadson",
"contributions": [
"code"
]
},
{
"login": "oliviergoulet5",
"name": "Olivier Goulet",
"avatar_url": "https://avatars.githubusercontent.com/u/17685861?v=4",
"profile": "https://github.com/oliviergoulet5",
"contributions": [
"code"
]
},
{
"login": "liamlows",
"name": "Liam Lowsley-Williams",
"avatar_url": "https://avatars.githubusercontent.com/u/1365914?v=4",
"profile": "https://github.com/liamlows",
"contributions": [
"code",
"doc"
]
},
{
"login": "uchar",
"name": "Omid",
"avatar_url": "https://avatars.githubusercontent.com/u/5172296?v=4",
"profile": "https://www.linkedin.com/in/oes-rioniz/",
"contributions": [
"test",
"code"
]
},
{
"login": "kathari00",
"name": "Katharina Eiserfey",
"avatar_url": "https://avatars.githubusercontent.com/u/42547712?v=4",
"profile": "https://github.com/kathari00",
"contributions": [
"code",
"test",
"doc"
]
},
{
"login": "luca-peruzzo",
"name": "Luca Peruzzo",
"avatar_url": "https://avatars.githubusercontent.com/u/69015314?v=4",
"profile": "https://github.com/luca-peruzzo",
"contributions": [
"code",
"test"
]
},
{
"login": "nima70",
"name": "Nima Shokouhfar",
"avatar_url": "https://avatars.githubusercontent.com/u/5094767?v=4",
"profile": "https://github.com/nima70",
"contributions": [
"code",
"test"
]
},
{
"login": "marvinruder",
"name": "Marvin A. Ruder",
"avatar_url": "https://avatars.githubusercontent.com/u/18495294?v=4",
"profile": "https://mruder.dev",
"contributions": [
"bug"
]
},
{
"login": "zvn2060",
"name": "HI_OuO",
"avatar_url": "https://avatars.githubusercontent.com/u/45450852?v=4",
"profile": "https://github.com/zvn2060",
"contributions": [
"code"
]
},
{
"login": "tripheo0412",
"name": "Tri Hoang",
"avatar_url": "https://avatars.githubusercontent.com/u/25382052?v=4",
"profile": "https://github.com/tripheo0412",
"contributions": [
"doc"
]
},
{
"login": "EternalSide",
"name": "Lesha",
"avatar_url": "https://avatars.githubusercontent.com/u/118743608?v=4",
"profile": "http://t.me/AAT_L",
"contributions": [
"code"
]
},
{
"login": "bacongobbler",
"name": "Matthew Fisher",
"avatar_url": "https://avatars.githubusercontent.com/u/1360539?v=4",
"profile": "https://blog.bacongobbler.com",
"contributions": [
"doc"
]
},
{
"login": "kodebach",
"name": "Klemens Böswirth",
"avatar_url": "https://avatars.githubusercontent.com/u/23529132?v=4",
"profile": "https://github.com/kodebach",
"contributions": [
"code"
]
},
{
"login": "wnmzzzz",
"name": "wnmzzzz",
"avatar_url": "https://avatars.githubusercontent.com/u/117174301?v=4",
"profile": "https://github.com/wnmzzzz",
"contributions": [
"test"
]
}
],
"contributorsPerLine": 7,

View File

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

25
.github/release.yaml vendored Normal file
View File

@ -0,0 +1,25 @@
changelog:
exclude:
labels:
- ignore-for-release
authors:
- octocat
categories:
- title: Breaking Changes 🛠
labels:
- breaking
- title: Exciting New Features 🎉
labels:
- feature
- title: Fixes 🔧
labels:
- fix
- title: Documentation 🔧
labels:
- docs
- title: CI 👷
labels:
- ci
- title: Other Changes
labels:
- '*'

View File

@ -3,6 +3,7 @@ on:
push:
branches:
- main
- v8
pull_request:
branches:
- main
@ -13,11 +14,11 @@ jobs:
runs-on: ubuntu-latest
if: ${{ !github.event.created && github.repository != 'garronej/ts-ci' }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
- uses: bahmutov/npm-install@v1
- name: If this step fails run 'npm run format' then commit again.
run: npm run _format --list-different
- name: If this step fails run 'yarn format' then commit again.
run: yarn format:check
test:
runs-on: ${{ matrix.os }}
needs: test_lint
@ -27,29 +28,30 @@ jobs:
os: [ ubuntu-latest ]
name: Test with Node v${{ matrix.node }} on ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node }}
- uses: bahmutov/npm-install@v1
- run: npm run build
- run: npm run test
- run: yarn build
- run: yarn test
#- run: yarn test:keycloakify-starter
storybook:
runs-on: ubuntu-latest
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
if: github.event_name == 'push'
needs: test
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: '18'
- uses: bahmutov/npm-install@v1
- run: npm run build-storybook
- run: yarn build-storybook -o ./build_storybook
- run: git remote set-url origin https://git:${GITHUB_TOKEN}@github.com/${{github.repository}}.git
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: npx -y -p gh-pages@3.1.0 gh-pages -d ./storybook-static -u "github-actions-bot <actions@github.com>"
- run: npx -y -p gh-pages@3.1.0 gh-pages -d ./build_storybook -u "github-actions-bot <actions@github.com>"
check_if_version_upgraded:
name: Check if version upgrade
@ -69,7 +71,7 @@ jobs:
is_upgraded_version: ${{ steps.step1.outputs.is_upgraded_version }}
is_pre_release: ${{steps.step1.outputs.is_pre_release }}
steps:
- uses: garronej/ts-ci@v2.1.2
- uses: garronej/ts-ci@v2.1.0
id: step1
with:
action_name: is_package_json_version_upgraded
@ -87,7 +89,7 @@ jobs:
needs:
- check_if_version_upgraded
steps:
- uses: softprops/action-gh-release@v2
- uses: softprops/action-gh-release@v1
with:
name: Release v${{ needs.check_if_version_upgraded.outputs.to_version }}
tag_name: v${{ needs.check_if_version_upgraded.outputs.to_version }}
@ -104,18 +106,18 @@ jobs:
- create_github_release
- check_if_version_upgraded
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v3
with:
ref: ${{ github.ref }}
- uses: actions/setup-node@v4
- uses: actions/setup-node@v3
with:
registry-url: https://registry.npmjs.org/
- uses: bahmutov/npm-install@v1
- run: npm run build
- run: npx -y -p denoify@1.6.13 enable_short_npm_import_path
- run: yarn build
- run: npx -y -p denoify@1.3.0 enable_short_npm_import_path
env:
DRY_RUN: "0"
- uses: garronej/ts-ci@v2.1.2
- uses: garronej/ts-ci@v2.1.0
with:
action_name: remove_dark_mode_specific_images_from_readme
- name: Publishing on NPM
@ -128,10 +130,7 @@ jobs:
echo "Can't publish on NPM, You must first create a secret called NPM_TOKEN that contains your NPM auth token. https://help.github.com/en/actions/automating-your-workflow-with-github-actions/creating-and-using-encrypted-secrets"
false
fi
EXTRA_ARGS=""
if [ "$IS_PRE_RELEASE" = "true" ]; then
EXTRA_ARGS="--tag next"
fi
EXTRA_ARGS="--tag v8"
npm publish $EXTRA_ARGS
env:
NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}

4
.gitignore vendored
View File

@ -48,8 +48,8 @@ jspm_packages
.idea
/src/login/i18n/messages_defaultSet/
/src/account/i18n/
/src/login/i18n/baseMessages/
/src/account/i18n/baseMessages/
# VS Code devcontainers
.devcontainer

View File

@ -6,11 +6,10 @@ node_modules/
/src/tools/types/
/build_keycloak/
/.vscode/
/src/login/i18n/messages_defaultSet/
/src/account/i18n/messages_defaultSet/
/src/login/i18n/baseMessages/
/src/account/i18n/baseMessages/
/dist_test
/sample_react_project/
/sample_custom_react_project/
/keycloakify_starter_test/
/.storybook/static/keycloak-resources/
/src/bin/start-keycloak/*.json
/.storybook/static/keycloak-resources/

View File

@ -1,24 +1,11 @@
{
"printWidth": 90,
"printWidth": 150,
"tabWidth": 4,
"useTabs": false,
"semi": true,
"singleQuote": false,
"quoteProps": "preserve",
"trailingComma": "none",
"bracketSpacing": true,
"arrowParens": "avoid",
"overrides": [
{
"files": "*.tsx",
"options": {
"printWidth": 150
}
},
{
"files": "useUserProfileForm.tsx",
"options": {
"printWidth": 150
}
}
]
"arrowParens": "avoid"
}

76
.storybook/Containers.js Normal file
View File

@ -0,0 +1,76 @@
import React from "react";
import { DocsContainer as BaseContainer } from "@storybook/addon-docs";
import { useDarkMode } from "storybook-dark-mode";
import { darkTheme, lightTheme } from "./customTheme";
import "./static/fonts/WorkSans/font.css";
export function DocsContainer({ children, context }) {
const isStorybookUiDark = useDarkMode();
const theme = isStorybookUiDark ? darkTheme : lightTheme;
const backgroundColor = theme.appBg;
return (
<>
<style>{`
body {
padding: 0 !important;
background-color: ${backgroundColor};
}
.docs-story {
background-color: ${backgroundColor};
}
[id^=story--] .container {
border: 1px dashed #e8e8e8;
}
.docblock-argstable-head th:nth-child(3), .docblock-argstable-body tr > td:nth-child(3) {
visibility: collapse;
}
.docblock-argstable-head th:nth-child(3), .docblock-argstable-body tr > td:nth-child(2) p {
font-size: 13px;
}
`}</style>
<BaseContainer
context={{
...context,
"storyById": id => {
const storyContext = context.storyById(id);
return {
...storyContext,
"parameters": {
...storyContext?.parameters,
"docs": {
...storyContext?.parameters?.docs,
"theme": isStorybookUiDark ? darkTheme : lightTheme
}
}
};
}
}}
>
{children}
</BaseContainer>
</>
);
}
export function CanvasContainer({ children }) {
return (
<>
<style>{`
body {
padding: 0 !important;
}
`}</style>
{children}
</>
);
}

35
.storybook/customTheme.js Normal file
View File

@ -0,0 +1,35 @@
import { create } from "@storybook/theming";
const brandImage = "logo.png";
const brandTitle = "Keycloakify";
const brandUrl = "https://github.com/keycloakify/keycloakify";
const fontBase = '"Work Sans", sans-serif';
const fontCode = "monospace";
export const darkTheme = create({
"base": "dark",
"appBg": "#1E1E1E",
"appContentBg": "#161616",
"barBg": "#161616",
"colorSecondary": "#8585F6",
"textColor": "#FFFFFF",
brandImage,
brandTitle,
brandUrl,
fontBase,
fontCode
});
export const lightTheme = create({
"base": "light",
"appBg": "#F6F6F6",
"appContentBg": "#FFFFFF",
"barBg": "#FFFFFF",
"colorSecondary": "#000091",
"textColor": "#212121",
brandImage,
brandTitle,
brandUrl,
fontBase,
fontCode
});

View File

@ -1,33 +0,0 @@
const brandImage = "logo.png";
const brandTitle = "Keycloakify";
const brandUrl = "https://github.com/keycloakify/keycloakify";
const fontBase = '"Work Sans", sans-serif';
const fontCode = "monospace";
export const darkTheme = {
base: "dark",
appBg: "#1E1E1E",
appContentBg: "#161616",
barBg: "#161616",
colorSecondary: "#8585F6",
textColor: "#FFFFFF",
brandImage,
brandTitle,
brandUrl,
fontBase,
fontCode
};
export const lightTheme: typeof darkTheme = {
base: "light",
appBg: "#F6F6F6",
appContentBg: "#FFFFFF",
barBg: "#FFFFFF",
colorSecondary: "#000091",
textColor: "#212121",
brandImage,
brandTitle,
brandUrl,
fontBase,
fontCode
};

View File

@ -1,13 +1,15 @@
module.exports = {
stories: [
"../stories/**/*.stories.tsx"
"stories": [
"../stories/**/*.stories.@(ts|tsx|mdx)"
],
addons: [
"addons": [
"@storybook/addon-links",
"@storybook/addon-essentials",
"storybook-dark-mode",
"@storybook/addon-a11y"
],
core: {
builder: "webpack5"
"core": {
"builder": "webpack5"
},
staticDirs: ["./static", "../dist/res/public"]
"staticDirs": ["./static"]
};

View File

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

View File

@ -1,23 +0,0 @@
<link rel="preload" href="/fonts/WorkSans/worksans-bold-webfont.woff2" as="font" crossorigin="anonymous">
<link rel="preload" href="/fonts/WorkSans/worksans-medium-webfont.woff2" as="font" crossorigin="anonymous">
<link rel="preload" href="/fonts/WorkSans/worksans-regular-webfont.woff2" as="font" crossorigin="anonymous">
<link rel="preload" href="/fonts/WorkSans/worksans-semibold-webfont.woff2" as="font" crossorigin="anonymous">
<link rel="stylesheet" type="text/css" href="/fonts/WorkSans/font.css">
<style>
body.sb-show-main.sb-main-padded {
padding: 0;
}
body:not(.kcBodyClass) {
background-color: #393939;
}
body.sb-show-preparing-docs > .sb-wrapper {
visibility: hidden;
}
body .sb-preparing-story {
visibility: hidden;
}
</style>

View File

@ -1,149 +1,125 @@
import { darkTheme, lightTheme } from "./customTheme";
import { create as createTheme } from "@storybook/theming";
import { DocsContainer, CanvasContainer } from "./Containers";
export const parameters = {
actions: { argTypesRegex: "^on[A-Z].*" },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/,
"actions": { "argTypesRegex": "^on[A-Z].*" },
"controls": {
"matchers": {
"color": /(background|color)$/i,
"date": /Date$/,
},
},
backgrounds: { disable: true },
darkMode: {
light: createTheme(lightTheme),
dark: createTheme(darkTheme),
"backgrounds": { "disable": true },
"darkMode": {
"light": lightTheme,
"dark": darkTheme,
},
controls: {
disable: true,
"docs": {
"container": DocsContainer
},
actions: {
disable: true
"controls": {
"disable": true,
},
viewport: {
viewports: {
"actions": {
"disable": true
},
"viewport": {
"viewports": {
"1440p": {
name: "1440p",
styles: {
width: "2560px",
height: "1440px",
"name": "1440p",
"styles": {
"width": "2560px",
"height": "1440px",
},
},
fullHD: {
name: "Full HD",
styles: {
width: "1920px",
height: "1080px",
"fullHD": {
"name": "Full HD",
"styles": {
"width": "1920px",
"height": "1080px",
},
},
macBookProBig: {
name: "MacBook Pro Big",
styles: {
width: "1024px",
height: "640px",
"macBookProBig": {
"name": "MacBook Pro Big",
"styles": {
"width": "1024px",
"height": "640px",
},
},
macBookProMedium: {
name: "MacBook Pro Medium",
styles: {
width: "1440px",
height: "900px",
"macBookProMedium": {
"name": "MacBook Pro Medium",
"styles": {
"width": "1440px",
"height": "900px",
},
},
macBookProSmall: {
name: "MacBook Pro Small",
styles: {
width: "1680px",
height: "1050px",
"macBookProSmall": {
"name": "MacBook Pro Small",
"styles": {
"width": "1680px",
"height": "1050px",
},
},
pcAgent: {
name: "PC Agent",
styles: {
width: "960px",
height: "540px",
"pcAgent": {
"name": "PC Agent",
"styles": {
"width": "960px",
"height": "540px",
},
},
iphone12Pro: {
name: "Iphone 12 pro",
styles: {
width: "390px",
height: "844px",
"iphone12Pro": {
"name": "Iphone 12 pro",
"styles": {
"width": "390px",
"height": "844px",
},
},
iphone5se: {
name: "Iphone 5/SE",
styles: {
width: "320px",
height: "568px",
"iphone5se": {
"name": "Iphone 5/SE",
"styles": {
"width": "320px",
"height": "568px",
},
},
ipadPro: {
name: "Ipad pro",
styles: {
width: "1240px",
height: "1366px",
"ipadPro": {
"name": "Ipad pro",
"styles": {
"width": "1240px",
"height": "1366px",
},
},
"Galaxy s9+": {
name: "Galaxy S9+",
styles: {
width: "320px",
height: "658px",
"name": "Galaxy S9+",
"styles": {
"width": "320px",
"height": "658px",
},
}
},
},
options: {
storySort: (a, b) =>
"options": {
"storySort": (a, b) =>
getHardCodedWeight(b[1].kind) - getHardCodedWeight(a[1].kind),
},
};
export const decorators = [
(Story) => (
<CanvasContainer>
<Story />
</CanvasContainer>
),
];
const { getHardCodedWeight } = (() => {
const orderedPagesPrefix = [
"Introduction",
"login/login.ftl",
"login/register-user-profile.ftl",
"login/register.ftl",
"login/terms.ftl",
"login/error.ftl",
"login/code.ftl",
"login/delete-account-confirm.ftl",
"login/delete-credential.ftl",
"login/frontchannel-logout.ftl",
"login/idp-review-user-profile.ftl",
"login/info.ftl",
"login/login-config-totp.ftl",
"login/login-idp-link-confirm.ftl",
"login/login-idp-link-email.ftl",
"login/login-oauth-grant.ftl",
"login/login-otp.ftl",
"login/login-page-expired.ftl",
"login/login-password.ftl",
"login/login-reset-otp.ftl",
"login/login-reset-password.ftl",
"login/login-update-password.ftl",
"login/login-update-profile.ftl",
"login/login-username.ftl",
"login/login-verify-email.ftl",
"login/login-x509-info.ftl",
"login/logout-confirm.ftl",
"login/saml-post-form.ftl",
"login/select-authenticator.ftl",
"login/update-email.ftl",
"login/webauthn-authenticate.ftl",
"login/webauthn-error.ftl",
"login/webauthn-register.ftl",
"login/login-oauth2-device-verify-user-code.ftl",
"login/login-recovery-authn-code-config.ftl",
"login/login-recovery-authn-code-input.ftl",
"account/account.ftl",
"account/password.ftl",
"account/federatedIdentity.ftl",
"account/log.ftl",
"account/sessions.ftl",
"account/totp.ftl",
];
function getHardCodedWeight(kind) {

View File

@ -1,3 +1,3 @@
Looking to contribute? Thank you! PR are more than welcome.
Please refers to [this documentation page](https://docs.keycloakify.dev/faq-and-help/contributing) that will help you get started.
Please refers to [this documentation page](https://docs.keycloakify.dev/contributing) that will help you get started.

376
README.md
View File

@ -2,11 +2,11 @@
<img src="https://user-images.githubusercontent.com/6702424/109387840-eba11f80-7903-11eb-9050-db1dad883f78.png">
</p>
<p align="center">
<i>🔏 Keycloak Theming for the Modern Web 🔏</i>
<i>🔏 Create Keycloak themes using React 🔏</i>
<br>
<br>
<a href="https://github.com/garronej/keycloakify/actions">
<img src="https://github.com/keycloakify/keycloakify/actions/workflows/ci.yaml/badge.svg">
<img src="https://github.com/garronej/keycloakify/workflows/ci/badge.svg?branch=main">
</a>
<a href="https://www.npmjs.com/package/keycloakify">
<img src="https://img.shields.io/npm/dm/keycloakify">
@ -14,15 +14,15 @@
<a href="https://github.com/garronej/keycloakify/blob/main/LICENSE">
<img src="https://img.shields.io/npm/l/keycloakify">
</a>
<a href="https://github.com/keycloakify/keycloakify/blob/729503fe31a155a823f46dd66ad4ff34ca274e0a/tsconfig.json#L14">
<img src="https://camo.githubusercontent.com/0f9fcc0ac1b8617ad4989364f60f78b2d6b32985ad6a508f215f14d8f897b8d3/68747470733a2f2f62616467656e2e6e65742f62616467652f547970655363726970742f7374726963742532302546302539462539322541412f626c7565">
</a>
<a href="https://github.com/thomasdarimont/awesome-keycloak">
<img src="https://awesome.re/mentioned-badge.svg"/>
</a>
<p align="center">
Check out our discord server!<br/>
<a href="https://discord.gg/mJdYJSdcm4">
<img src="https://dcbadge.limes.pink/api/server/kYFZG7fQmn"/>
</a>
</p>
<a href="https://discord.gg/kYFZG7fQmn">
<img src="https://img.shields.io/discord/1097708346976505977"/>
</a>
<p align="center">
<a href="https://www.keycloakify.dev">Home</a>
-
@ -36,65 +36,34 @@
<p align="center">
<i>This build tool generates a Keycloak theme <a href="https://www.keycloakify.dev">Learn more</a></i>
<br/>
<br/>
<img width="400" src="https://github.com/user-attachments/assets/6bf3bef9-00b0-4460-97b9-0d2da8500798">
<img src="https://user-images.githubusercontent.com/6702424/110260457-a1c3d380-7fac-11eb-853a-80459b65626b.png">
</p>
Keycloakify is fully compatible with Keycloak from version 11 to 26...[and beyond](https://github.com/keycloakify/keycloakify/discussions/346#discussioncomment-5889791)
> Whether or not React is your preferred framework, Keycloakify
> offers a solid option for building Keycloak themes.
> It's not just a convenient way to create a Keycloak theme
> when using React; it's a well-regarded solution that many
> developers appreciate.
> 📣 **Keycloakify 26 Released**
> Themes built with Keycloakify versions **prior** to Keycloak 26 are **incompatible** with Keycloak 26.
> To ensure compatibility, simply upgrade to the latest Keycloakify version for your major release (v10 or v11) and rebuild your theme.
> No breaking changes have been introduced, but the target version ranges have been updated. For more details, see [this guide](https://docs.keycloakify.dev/features/compiler-options/keycloakversiontargets).
> 📣 🛑 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
> merged into Keycloak! 🥳 Credit to @xgp. We are now waiting for a new Keycloak release to be published.
## Sponsors
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. (Well except for Keycloak 22's Account theme obviously but this was hopefully a one time debacle)
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).
Project backers, we trust and recommend their services.
## Sponsor 👼
<br/>
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.
<div align="center">
[Cloud IAM](https://cloud-iam.com/?mtm_campaign=keycloakify-deal&mtm_source=keycloakify-github) provides the following services:
![Logo Dark](https://github.com/user-attachments/assets/d8f6b6f5-3de4-4adc-ba15-cb4074e8309b#gh-dark-mode-only)
</div>
<div align="center">
![Logo Light](https://github.com/user-attachments/assets/20736d6f-f22d-4a9d-9dfe-93be209a8191#gh-light-mode-only)
</div>
<br/>
<p align="center">
<i><a href="https://phasetwo.io/?utm_source=keycloakify"><strong>Keycloak as a Service</strong></a> - Keycloak community contributors of popular <a href="https://github.com/p2-inc#our-extensions-?utm_source=keycloakify">extensions</a> providing free and dedicated <a href="https://phasetwo.io/hosting/?utm_source=keycloakify">Keycloak hosting</a> and enterprise <a href="https://phasetwo.io/support/?utm_source=keycloakify">Keycloak support</a> to businesses of all sizes.</i>
</p>
<br/>
<br/>
<br/>
<div align="center">
![Logo Dark](https://github.com/user-attachments/assets/dd3925fb-a58a-4e91-b360-69c2fa1f1087#gh-dark-mode-only)
</div>
<div align="center">
![Logo Light](https://github.com/user-attachments/assets/6c00c201-eed7-485a-a887-70891559d69b#gh-light-mode-only)
</div>
<br/>
<p align="center">
<a href="https://www.zone2.tech/services/keycloak-consulting">
<i><strong>Keycloak Consulting Services</strong> - Your partner in Keycloak deployment, configuration, and extension development for optimized identity management solutions.</i>
</a>
</p>
- Simplify and secure your Keycloak Identity and Access Management. Keycloak as a Service.
- Custom theme building for your brand using Keycloakify.
<div align="center">
@ -109,11 +78,13 @@ Project backers, we trust and recommend their services.
</div>
<p align="center">
<a href="https://cloud-iam.com/?mtm_campaign=keycloakify-deal&mtm_source=keycloakify-github"><strong>Managed Keycloak Provider</strong> - With Cloud-IAM powering your Keycloak clusters, you can sleep easy knowing you've got the software and the experts you need for operational excellence. Cloud IAM is a french company. </a>
<br/>
Use code <code>keycloakify5</code> at checkout for a 5% discount.
<i>Checkout <a href="https://cloud-iam.com/?mtm_campaign=keycloakify-deal&mtm_source=keycloakify-github">Cloud IAM</a> and use promo code <code>keycloakify5</code></i>
<br/>
<i>5% of your annual subscription will be donated to us, and you'll get 5% off too.</i>
</p>
Thank you, [Cloud IAM](https://cloud-iam.com/?mtm_campaign=keycloakify-deal&mtm_source=keycloakify-github), for your support!
## Contributors ✨
Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)):
@ -148,31 +119,6 @@ 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/zavoloklom"><img src="https://avatars.githubusercontent.com/u/4151869?v=4?s=100" width="100px;" alt="Sergey Kupletsky"/><br /><sub><b>Sergey Kupletsky</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=zavoloklom" title="Tests">⚠️</a> <a href="https://github.com/keycloakify/keycloakify/commits?author=zavoloklom" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/rome-user"><img src="https://avatars.githubusercontent.com/u/114131048?v=4?s=100" width="100px;" alt="rome-user"/><br /><sub><b>rome-user</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=rome-user" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/celinepelletier"><img src="https://avatars.githubusercontent.com/u/82821620?v=4?s=100" width="100px;" alt="Céline Pelletier"/><br /><sub><b>Céline Pelletier</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=celinepelletier" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/xgp"><img src="https://avatars.githubusercontent.com/u/244253?v=4?s=100" width="100px;" alt="Garth"/><br /><sub><b>Garth</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=xgp" title="Code">💻</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/BlackVoid"><img src="https://avatars.githubusercontent.com/u/673720?v=4?s=100" width="100px;" alt="Felix Gustavsson"/><br /><sub><b>Felix Gustavsson</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=BlackVoid" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://m-siemens.de/"><img src="https://avatars.githubusercontent.com/u/1873922?v=4?s=100" width="100px;" alt="Markus Siemens"/><br /><sub><b>Markus Siemens</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=msiemens" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/law108000"><img src="https://avatars.githubusercontent.com/u/8112024?v=4?s=100" width="100px;" alt="Rlok"/><br /><sub><b>Rlok</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=law108000" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Moulyy"><img src="https://avatars.githubusercontent.com/u/115405804?v=4?s=100" width="100px;" alt="Moulyy"/><br /><sub><b>Moulyy</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=Moulyy" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/madmadson"><img src="https://avatars.githubusercontent.com/u/798831?v=4?s=100" width="100px;" alt="Tobias Matt"/><br /><sub><b>Tobias Matt</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=madmadson" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/oliviergoulet5"><img src="https://avatars.githubusercontent.com/u/17685861?v=4?s=100" width="100px;" alt="Olivier Goulet"/><br /><sub><b>Olivier Goulet</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=oliviergoulet5" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/liamlows"><img src="https://avatars.githubusercontent.com/u/1365914?v=4?s=100" width="100px;" alt="Liam Lowsley-Williams"/><br /><sub><b>Liam Lowsley-Williams</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=liamlows" title="Code">💻</a> <a href="https://github.com/keycloakify/keycloakify/commits?author=liamlows" title="Documentation">📖</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://www.linkedin.com/in/oes-rioniz/"><img src="https://avatars.githubusercontent.com/u/5172296?v=4?s=100" width="100px;" alt="Omid"/><br /><sub><b>Omid</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=uchar" title="Tests">⚠️</a> <a href="https://github.com/keycloakify/keycloakify/commits?author=uchar" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/kathari00"><img src="https://avatars.githubusercontent.com/u/42547712?v=4?s=100" width="100px;" alt="Katharina Eiserfey"/><br /><sub><b>Katharina Eiserfey</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=kathari00" title="Code">💻</a> <a href="https://github.com/keycloakify/keycloakify/commits?author=kathari00" title="Tests">⚠️</a> <a href="https://github.com/keycloakify/keycloakify/commits?author=kathari00" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/luca-peruzzo"><img src="https://avatars.githubusercontent.com/u/69015314?v=4?s=100" width="100px;" alt="Luca Peruzzo"/><br /><sub><b>Luca Peruzzo</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=luca-peruzzo" title="Code">💻</a> <a href="https://github.com/keycloakify/keycloakify/commits?author=luca-peruzzo" title="Tests">⚠️</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/nima70"><img src="https://avatars.githubusercontent.com/u/5094767?v=4?s=100" width="100px;" alt="Nima Shokouhfar"/><br /><sub><b>Nima Shokouhfar</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=nima70" title="Code">💻</a> <a href="https://github.com/keycloakify/keycloakify/commits?author=nima70" title="Tests">⚠️</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://mruder.dev"><img src="https://avatars.githubusercontent.com/u/18495294?v=4?s=100" width="100px;" alt="Marvin A. Ruder"/><br /><sub><b>Marvin A. Ruder</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/issues?q=author%3Amarvinruder" title="Bug reports">🐛</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/zvn2060"><img src="https://avatars.githubusercontent.com/u/45450852?v=4?s=100" width="100px;" alt="HI_OuO"/><br /><sub><b>HI_OuO</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=zvn2060" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/tripheo0412"><img src="https://avatars.githubusercontent.com/u/25382052?v=4?s=100" width="100px;" alt="Tri Hoang"/><br /><sub><b>Tri Hoang</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=tripheo0412" title="Documentation">📖</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="http://t.me/AAT_L"><img src="https://avatars.githubusercontent.com/u/118743608?v=4?s=100" width="100px;" alt="Lesha"/><br /><sub><b>Lesha</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=EternalSide" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://blog.bacongobbler.com"><img src="https://avatars.githubusercontent.com/u/1360539?v=4?s=100" width="100px;" alt="Matthew Fisher"/><br /><sub><b>Matthew Fisher</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=bacongobbler" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/kodebach"><img src="https://avatars.githubusercontent.com/u/23529132?v=4?s=100" width="100px;" alt="Klemens Böswirth"/><br /><sub><b>Klemens Böswirth</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=kodebach" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/wnmzzzz"><img src="https://avatars.githubusercontent.com/u/117174301?v=4?s=100" width="100px;" alt="wnmzzzz"/><br /><sub><b>wnmzzzz</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=wnmzzzz" title="Tests">⚠️</a></td>
</tr>
</tbody>
</table>
@ -181,3 +127,259 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
<!-- prettier-ignore-end -->
<!-- ALL-CONTRIBUTORS-LIST:END -->
# 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 🍾
- Account theme support 🚀
- It's much easier to customize pages at the CSS level, you can now see in the browser dev tool the customizable classes.
- New interactive CLI tool `npx eject-keycloak-page`, that enables to select the page you want to customize at the component level.
- There is [a Storybook](https://storybook.keycloakify.dev)
- [Remember me is fixed](https://github.com/keycloakify/keycloakify/pull/272)
## 6.13
- Build work behind corporate proxies, [see issue](https://github.com/keycloakify/keycloakify/issues/257).
## 6.12
Massive improvement in the developer experience:
- There is now only one starter repo: https://github.com/codegouvfr/keycloakify-starter
- A lot of comments have been added in the code of the starter to make it easier to get started.
- The doc has been updated: https://docs.keycloakify.dev
- A lot of improvements in the type system.
## 6.11.4
- You no longer need to have Maven installed to build the theme. Thanks to @lordvlad, [see PR](https://github.com/keycloakify/keycloakify/pull/239).
- Feature new build options: [`bundler`](https://docs.keycloakify.dev/build-options#keycloakify.bundler), [`groupId`](https://docs.keycloakify.dev/build-options#keycloakify.groupid), [`artifactId`](https://docs.keycloakify.dev/build-options#keycloakify.artifactid), [`version`](https://docs.keycloakify.dev/build-options#version).
Theses options can be user to customize the output name of the .jar. You can use environnement variables to overrides the values read in the package.json. Thanks to @lordvlad.
## 6.10.0
- Widows compat (thanks to @lordvlad, [see PR](https://github.com/keycloakify/keycloakify/pull/226)). WSL is no longer required 🎉
## 6.8.4
- `@emotion/react` is no longer a peer dependency of Keycloakify.
## 6.8.0
- It is now possible to pass a custom `<Template />` component as a prop to `<KcApp />` and every
individual page (`<Login />`, `<RegisterUserProfile />`, ...) it enables to customize only the header and footer for
example without having to switch to a full-component level customization. [See issue](https://github.com/keycloakify/keycloakify/issues/191).
## 6.7.0
- Add support for `webauthn-authenticate.ftl` thanks to [@mstrodl](https://github.com/Mstrodl)'s hacktoberfest [PR](https://github.com/keycloakify/keycloakify/pull/185).
## 6.6.0
- Add support for `login-password.ftl` thanks to [@mstrodl](https://github.com/Mstrodl)'s hacktoberfest [PR](https://github.com/keycloakify/keycloakify/pull/184).
## 6.5.0
- Add support for `login-username.ftl` thanks to [@mstrodl](https://github.com/Mstrodl)'s hacktoberfest [PR](https://github.com/keycloakify/keycloakify/pull/183).
## 6.4.0
- You can now optionally pass a `doFetchDefaultThemeResources: boolean` prop to every page component and the default `<KcApp />`
This enables you to prevent the default CSS and JS that comes with the builtin Keycloak theme to be downloaded.
You'll get [a black slate](https://user-images.githubusercontent.com/6702424/192619083-4baa5df4-4a21-4ec7-8e28-d200d1208299.png).
## 6.0.0
- Bundle size drastically reduced, locals and component dynamically loaded.
- First print much quicker, use of React.lazy() everywhere.
- Real i18n API.
- Actual documentation for build options.
Checkout [the migration guide](https://docs.keycloakify.dev/v5-to-v6)
## 5.8.0
- [React.lazy()](https://reactjs.org/docs/code-splitting.html#reactlazy) support 🎉. [#141](https://github.com/keycloakify/keycloakify/issues/141)
## 5.7.0
- Feat `logout-confirm.ftl`. [PR](https://github.com/keycloakify/keycloakify/pull/120)
## 5.6.4
Fix `login-verify-email.ftl` page. [Before](https://user-images.githubusercontent.com/6702424/177436014-0bad22c4-5bfb-45bb-8fc9-dad65143cd0c.png) - [After](https://user-images.githubusercontent.com/6702424/177435797-ec5d7db3-84cf-49cb-8efc-3427a81f744e.png)
## v5.6.0
Add support for `login-config-totp.ftl` page [#127](https://github.com/keycloakify/keycloakify/pull/127).
## v5.3.0
Rename `keycloak_theme_email` to `keycloak_email`.
If you already had a `keycloak_theme_email` you should rename it `keycloak_email`.
## v5.0.0
[Migration guide](https://github.com/garronej/keycloakify-demo-app/blob/a5b6a50f24bc25e082931f5ad9ebf47492acd12a/src/index.tsx#L46-L63)
New i18n system.
Import of terms and services have changed. [See example](https://github.com/garronej/keycloakify-demo-app/blob/a5b6a50f24bc25e082931f5ad9ebf47492acd12a/src/index.tsx#L46-L63).
## v4.10.0
Add `login-idp-link-email.ftl` page [See PR](https://github.com/keycloakify/keycloakify/pull/92).
## v4.8.0
[Email template customization.](#email-template-customization)
## v4.7.4
**M1 Mac** support (for testing locally with a dockerized Keycloak).
## v4.7.2
> WARNING: This is broken.
> Testing with local Keycloak container working with M1 Mac. Thanks to [@eduardosanzb](https://github.com/keycloakify/keycloakify/issues/43#issuecomment-975699658).
> Be aware: When running M1s you are testing with Keycloak v15 else the local container spun will be a Keycloak v16.1.0.
## v4.7.0
Register with user profile enabled: Out of the box `options` validator support.
[Example](https://user-images.githubusercontent.com/6702424/158911163-81e6bbe8-feb0-4dc8-abff-de199d7a678e.mov)
## v4.6.0
`tss-react` and `powerhooks` are no longer peer dependencies of `keycloakify`.
After updating Keycloakify you can remove `tss-react` and `powerhooks` from your dependencies if you don't use them explicitly.
## v4.5.3
There is a new recommended way to setup highly customized theme. See [here](https://github.com/garronej/keycloakify-demo-app/blob/look_and_feel/src/KcApp/KcApp.tsx).
Unlike with [the previous recommended method](https://github.com/garronej/keycloakify-demo-app/blob/a51660578bea15fb3e506b8a2b78e1056c6d68bb/src/KcApp/KcApp.tsx),
with this new method your theme wont break on minor Keycloakify update.
## v4.3.0
Feature [`login-update-password.ftl`](https://user-images.githubusercontent.com/6702424/147517600-6191cf72-93dd-437b-a35c-47180142063e.png).
Every time a page is added it's a breaking change for non CSS-only theme.
Change [this](https://github.com/garronej/keycloakify-demo-app/blob/df664c13c77ce3c53ac7df0622d94d04e76d3f9f/src/KcApp/KcApp.tsx#L17) and [this](https://github.com/garronej/keycloakify-demo-app/blob/df664c13c77ce3c53ac7df0622d94d04e76d3f9f/src/KcApp/KcApp.tsx#L37) to update.
## v4
- Out of the box [frontend form validation](#user-profile-and-frontend-form-validation) 🥳
- Improvements (and breaking changes in `import { useKcMessage } from "keycloakify"`.
## v3
No breaking changes except that `@emotion/react`, [`tss-react`](https://www.npmjs.com/package/tss-react) and [`powerhooks`](https://www.npmjs.com/package/powerhooks) are now `peerDependencies` instead of being just dependencies.
It's important to avoid problem when using `keycloakify` alongside [`mui`](https://mui.com) and
[when passing params from the app to the login page](https://github.com/keycloakify/keycloakify#implement-context-persistence-optional).
## v2.5
- Feature [Use advanced message](https://github.com/keycloakify/keycloakify/blob/59f106bf9e210b63b190826da2bf5f75fc8b7644/src/lib/i18n/useKcMessage.tsx#L53-L66)
and [`messagesPerFields`](https://github.com/keycloakify/keycloakify/blob/59f106bf9e210b63b190826da2bf5f75fc8b7644/src/lib/getKcContext/KcContextBase.ts#L70-L75) (implementation [here](https://github.com/keycloakify/keycloakify/blob/59f106bf9e210b63b190826da2bf5f75fc8b7644/src/bin/build-keycloak-theme/generateFtl/common.ftl#L130-L189))
- Test container now uses Keycloak version `15.0.2`.
## v2
- It's now possible to implement custom `.ftl` pages.
- Support for Keycloak plugins that introduce non standard ftl values.
(Like for example [this plugin](https://github.com/micedre/keycloak-mail-whitelisting) that define `authorizedMailDomains` in `register.ftl`).

View File

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

View File

@ -1,26 +1,39 @@
{
"name": "keycloakify",
"version": "11.8.23",
"description": "Framework to create custom Keycloak UIs",
"version": "8.4.5",
"description": "Create Keycloak themes using React",
"repository": {
"type": "git",
"url": "git://github.com/keycloakify/keycloakify.git"
},
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"prepare": "tsx scripts/generate-i18n-messages.ts",
"build": "tsx scripts/build/main.ts",
"storybook": "tsx scripts/start-storybook.ts",
"link-in-starter": "tsx scripts/link-in-starter.ts",
"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",
"test": "yarn test:types && vitest run",
"test:keycloakify-starter": "ts-node scripts/test-keycloakify-starter",
"test:types": "tsc -p test/tsconfig.json --noEmit",
"_format": "prettier '**/*.{ts,tsx,json,md}'",
"format": "yarn _format --write",
"link-in-app": "tsx scripts/link-in-app.ts",
"build-storybook": "tsx scripts/build-storybook.ts",
"dump-keycloak-realm": "tsx scripts/dump-keycloak-realm.ts"
"format:check": "yarn _format --list-different",
"generate-i18n-messages": "ts-node --skipProject scripts/generate-i18n-messages.ts",
"link-in-app": "ts-node --skipProject scripts/link-in-app.ts",
"link-in-starter": "yarn link-in-app keycloakify-starter",
"watch-in-starter": "yarn build && yarn link-in-starter && (concurrently \"tsc -p src -w\" \"tsc-alias -p src/tsconfig.json\" \"tsc -p src/bin -w\")",
"copy-keycloak-resources-to-storybook-static": "PUBLIC_DIR_PATH=.storybook/static node dist/bin/copy-keycloak-resources-to-public.js",
"storybook": "yarn build && yarn copy-keycloak-resources-to-storybook-static && start-storybook -p 6006",
"build-storybook": "yarn build && yarn copy-keycloak-resources-to-storybook-static && build-storybook"
},
"bin": {
"keycloakify": "dist/bin/main.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",
"initialize-email-theme": "dist/bin/initialize-email-theme.js",
"keycloakify": "dist/bin/keycloakify/index.js"
},
"lint-staged": {
"*.{ts,tsx,json,md}": [
@ -35,91 +48,83 @@
"author": "u/garronej",
"license": "MIT",
"files": [
"src/",
"dist/",
"!dist/tsconfig.tsbuildinfo",
"!dist/bin/",
"dist/bin/**/*.d.ts",
"dist/bin/main.js",
"dist/bin/*.index.js",
"dist/bin/*.node",
"dist/bin/shared/constants.js",
"dist/bin/shared/constants.js.map",
"dist/bin/shared/customHandler.js",
"dist/bin/shared/customHandler.js.map",
"!dist/vite-plugin/",
"dist/vite-plugin/index.js",
"dist/vite-plugin/index.d.ts",
"dist/vite-plugin/vite-plugin.d.ts"
"!dist/bin/tsconfig.tsbuildinfo"
],
"keywords": [
"bluehats",
"keycloak",
"react",
"theme",
"FreeMarker",
"ftl",
"login",
"register",
"account",
"bluehats"
"register"
],
"homepage": "https://www.keycloakify.dev",
"dependencies": {
"tsafe": "^1.8.5"
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
},
"devDependencies": {
"@babel/core": "^7.24.5",
"@babel/generator": "^7.24.5",
"@babel/parser": "^7.24.5",
"@babel/preset-env": "7.24.8",
"@babel/types": "^7.24.5",
"@emotion/react": "^11.11.4",
"@octokit/rest": "^20.1.1",
"@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",
"@storybook/addon-interactions": "^6.5.13",
"@storybook/addon-links": "^6.5.13",
"@storybook/builder-webpack5": "^6.5.13",
"@storybook/manager-webpack5": "^6.5.13",
"@storybook/react": "^6.5.13",
"@storybook/testing-library": "^0.0.13",
"@types/babel__generator": "^7.6.4",
"@types/dompurify": "^2.0.0",
"@types/make-fetch-happen": "^10.0.1",
"@types/minimist": "^1.2.2",
"@types/node": "^18.15.3",
"@types/properties-parser": "^0.3.3",
"@types/react": "^18.0.35",
"@types/react-dom": "^18.0.11",
"@types/yauzl": "^2.10.3",
"@vercel/ncc": "^0.38.1",
"babel-loader": "9.1.3",
"chalk": "^4.1.2",
"cheerio": "1.0.0-rc.12",
"chokidar-cli": "^3.0.0",
"cli-select": "^1.1.2",
"dompurify": "^3.1.6",
"@types/yauzl": "^2.10.0",
"@types/yazl": "^2.4.2",
"concurrently": "^8.0.1",
"copyfiles": "^2.4.1",
"eslint-plugin-storybook": "^0.6.7",
"evt": "^2.5.8",
"html-entities": "^2.5.2",
"husky": "^4.3.8",
"isomorphic-dompurify": "^2.15.0",
"lint-staged": "^11.0.0",
"magic-string": "^0.30.7",
"make-fetch-happen": "^11.0.3",
"powerhooks": "^1.0.19",
"prettier": "^3.2.5",
"powerhooks": "^0.26.7",
"prettier": "^2.3.0",
"properties-parser": "^0.3.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"recast": "^0.23.3",
"run-exclusive": "^2.2.19",
"rimraf": "^3.0.2",
"scripting-tools": "^0.19.13",
"storybook-dark-mode": "^1.1.2",
"termost": "^v0.12.1",
"tsc-alias": "^1.8.10",
"tss-react": "^4.9.10",
"tsx": "^4.15.5",
"typescript": "^4.9.4",
"vite": "^5.2.11",
"vitest": "^1.6.0",
"webpack": "5.93.0",
"webpack-cli": "5.1.4",
"ts-node": "^10.9.1",
"tsc-alias": "^1.8.3",
"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",
"cheerio": "1.0.0-rc.5",
"cli-select": "^1.1.2",
"evt": "^2.4.18",
"make-fetch-happen": "^11.0.3",
"minimal-polyfills": "^2.2.2",
"minimist": "^1.2.6",
"path-browserify": "^1.0.1",
"react-markdown": "^5.0.3",
"recast": "^0.23.3",
"rfc4648": "^1.5.2",
"tsafe": "^1.6.0",
"yauzl": "^2.10.0",
"yazl": "^2.5.1",
"zod": "^3.17.10"
}
}

View File

@ -1,6 +1,6 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"baseBranches": ["main"],
"baseBranches": ["main", "landingpage"],
"extends": ["config:base"],
"dependencyDashboard": false,
"bumpVersion": "patch",

View File

@ -1,4 +0,0 @@
import { run } from "./shared/run";
run("yarn build");
run("npx build-storybook");

View File

@ -1,79 +0,0 @@
import * as fs from "fs";
import { join as pathJoin } from "path";
import { transformCodebase } from "../../src/bin/tools/transformCodebase";
import { downloadKeycloakDefaultTheme } from "../shared/downloadKeycloakDefaultTheme";
import { WELL_KNOWN_DIRECTORY_BASE_NAME } from "../../src/bin/shared/constants";
import { getThisCodebaseRootDirPath } from "../../src/bin/tools/getThisCodebaseRootDirPath";
import { accountMultiPageSupportedLanguages } from "../generate-i18n-messages";
import * as fsPr from "fs/promises";
export async function createAccountV1Dir() {
const { extractedDirPath } = await downloadKeycloakDefaultTheme({
keycloakVersionId: "FOR_ACCOUNT_MULTI_PAGE"
});
const destDirPath = pathJoin(
getThisCodebaseRootDirPath(),
"dist",
"res",
"account-v1"
);
await fsPr.rm(destDirPath, { recursive: true, force: true });
transformCodebase({
srcDirPath: pathJoin(extractedDirPath, "base", "account"),
destDirPath
});
transformCodebase({
srcDirPath: pathJoin(extractedDirPath, "keycloak", "account", "resources"),
destDirPath: pathJoin(destDirPath, "resources")
});
transformCodebase({
srcDirPath: pathJoin(extractedDirPath, "keycloak", "common", "resources"),
destDirPath: pathJoin(
destDirPath,
"resources",
WELL_KNOWN_DIRECTORY_BASE_NAME.RESOURCES_COMMON
)
});
fs.writeFileSync(
pathJoin(destDirPath, "theme.properties"),
Buffer.from(
[
"accountResourceProvider=account-v1",
"",
`locales=${accountMultiPageSupportedLanguages.join(",")}`,
"",
"styles=" +
[
"css/account.css",
"img/icon-sidebar-active.png",
"img/logo.png",
...[
"patternfly.min.css",
"patternfly-additions.min.css",
"patternfly-additions.min.css"
].map(
fileBasename =>
`${WELL_KNOWN_DIRECTORY_BASE_NAME.RESOURCES_COMMON}/node_modules/patternfly/dist/css/${fileBasename}`
)
].join(" "),
"",
"##### css classes for form buttons",
"# main class used for all buttons",
"kcButtonClass=btn",
"# classes defining priority of the button - primary or default (there is typically only one priority button for the form)",
"kcButtonPrimaryClass=btn-primary",
"kcButtonDefaultClass=btn-default",
"# classes defining size of the button",
"kcButtonLargeClass=btn-lg",
""
].join("\n"),
"utf8"
)
);
}

View File

@ -1,73 +0,0 @@
import { join as pathJoin } from "path";
import { downloadKeycloakDefaultTheme } from "../shared/downloadKeycloakDefaultTheme";
import { transformCodebase } from "../../src/bin/tools/transformCodebase";
import { existsAsync } from "../../src/bin/tools/fs.existsAsync";
import { getThisCodebaseRootDirPath } from "../../src/bin/tools/getThisCodebaseRootDirPath";
import { WELL_KNOWN_DIRECTORY_BASE_NAME } from "../../src/bin/shared/constants";
import { assert, type Equals } from "tsafe/assert";
import * as fsPr from "fs/promises";
export async function createPublicKeycloakifyDevResourcesDir() {
await Promise.all(
(["login", "account"] as const).map(async themeType => {
const { extractedDirPath } = await downloadKeycloakDefaultTheme({
keycloakVersionId: (() => {
switch (themeType) {
case "login":
return "FOR_LOGIN_THEME";
case "account":
return "FOR_ACCOUNT_MULTI_PAGE";
}
assert<Equals<typeof themeType, never>>();
})()
});
const destDirPath = pathJoin(
getThisCodebaseRootDirPath(),
"dist",
"res",
"public",
WELL_KNOWN_DIRECTORY_BASE_NAME.KEYCLOAKIFY_DEV_RESOURCES,
themeType
);
await fsPr.rm(destDirPath, { recursive: true, force: true });
base_resources: {
const srcDirPath = pathJoin(
extractedDirPath,
"base",
themeType,
"resources"
);
if (!(await existsAsync(srcDirPath))) {
break base_resources;
}
transformCodebase({
srcDirPath,
destDirPath
});
}
transformCodebase({
srcDirPath: pathJoin(
extractedDirPath,
"keycloak",
themeType,
"resources"
),
destDirPath
});
transformCodebase({
srcDirPath: pathJoin(extractedDirPath, "keycloak", "common", "resources"),
destDirPath: pathJoin(
destDirPath,
WELL_KNOWN_DIRECTORY_BASE_NAME.RESOURCES_COMMON
)
});
})
);
}

View File

@ -1,39 +0,0 @@
import { downloadAndExtractArchive } from "../../src/bin/tools/downloadAndExtractArchive";
import { cacheDirPath } from "../shared/cacheDirPath";
import { getProxyFetchOptions } from "../../src/bin/tools/fetchProxyOptions";
import { getThisCodebaseRootDirPath } from "../../src/bin/tools/getThisCodebaseRootDirPath";
import { existsAsync } from "../../src/bin/tools/fs.existsAsync";
import * as fs from "fs/promises";
import {
KEYCLOAKIFY_LOGGING_VERSION,
KEYCLOAKIFY_LOGIN_JAR_BASENAME
} from "../../src/bin/shared/constants";
import { join as pathJoin } from "path";
export async function downloadKeycloakifyLogging(params: { distDirPath: string }) {
const { distDirPath } = params;
const jarFilePath = pathJoin(
distDirPath,
"src",
"bin",
"start-keycloak",
KEYCLOAKIFY_LOGIN_JAR_BASENAME
);
if (await existsAsync(jarFilePath)) {
return;
}
const { archiveFilePath } = await downloadAndExtractArchive({
cacheDirPath,
fetchOptions: getProxyFetchOptions({
npmConfigGetCwd: getThisCodebaseRootDirPath()
}),
url: `https://github.com/keycloakify/keycloakify-logging/releases/download/${KEYCLOAKIFY_LOGGING_VERSION}/keycloakify-logging-${KEYCLOAKIFY_LOGGING_VERSION}.jar`,
uniqueIdOfOnArchiveFile: "no extraction",
onArchiveFile: async () => {}
});
await fs.cp(archiveFilePath, jarFilePath);
}

View File

@ -1,188 +0,0 @@
import * as fs from "fs";
import { join } from "path";
import { assert } from "tsafe/assert";
import { transformCodebase } from "../../src/bin/tools/transformCodebase";
import { createPublicKeycloakifyDevResourcesDir } from "./createPublicKeycloakifyDevResourcesDir";
import { createAccountV1Dir } from "./createAccountV1Dir";
import chalk from "chalk";
import { run } from "../shared/run";
import { vendorFrontendDependencies } from "./vendorFrontendDependencies";
import { downloadKeycloakifyLogging } from "./downloadKeycloakifyLogging";
(async () => {
console.log(chalk.cyan("Building Keycloakify..."));
const startTime = Date.now();
if (fs.existsSync(join("dist", "bin", "main.original.js"))) {
fs.renameSync(
join("dist", "bin", "main.original.js"),
join("dist", "bin", "main.js")
);
fs.readdirSync(join("dist", "bin")).forEach(fileBasename => {
if (/[0-9]\.index.js/.test(fileBasename) || fileBasename.endsWith(".node")) {
fs.rmSync(join("dist", "bin", fileBasename));
}
});
}
run(`npx tsc -p ${join("src", "bin", "tsconfig.json")}`);
if (
!fs
.readFileSync(join("dist", "bin", "main.js"))
.toString("utf8")
.includes("__nccwpck_require__")
) {
fs.cpSync(
join("dist", "bin", "main.js"),
join("dist", "bin", "main.original.js")
);
}
run(
`npx ncc build ${join("dist", "bin", "main.js")} --external prettier -o ${join("dist", "ncc_out")}`
);
transformCodebase({
srcDirPath: join("dist", "ncc_out"),
destDirPath: join("dist", "bin"),
transformSourceCode: ({ fileRelativePath, sourceCode }) => {
if (fileRelativePath === "index.js") {
return {
newFileName: "main.js",
modifiedSourceCode: sourceCode
};
}
return { modifiedSourceCode: sourceCode };
}
});
fs.rmSync(join("dist", "ncc_out"), { recursive: true });
{
let hasBeenPatched = false;
fs.readdirSync(join("dist", "bin")).forEach(fileBasename => {
if (fileBasename !== "main.js" && !fileBasename.endsWith(".index.js")) {
return;
}
const { hasBeenPatched: hasBeenPatched_i } = patchDeprecatedBufferApiUsage(
join("dist", "bin", fileBasename)
);
if (hasBeenPatched_i) {
hasBeenPatched = true;
}
});
assert(hasBeenPatched);
}
fs.chmodSync(
join("dist", "bin", "main.js"),
fs.statSync(join("dist", "bin", "main.js")).mode |
fs.constants.S_IXUSR |
fs.constants.S_IXGRP |
fs.constants.S_IXOTH
);
run(`npx tsc -p ${join("src", "tsconfig.json")}`);
run(`npx tsc-alias -p ${join("src", "tsconfig.json")}`);
vendorFrontendDependencies({ distDirPath: join(process.cwd(), "dist") });
if (fs.existsSync(join("dist", "vite-plugin", "index.original.js"))) {
fs.renameSync(
join("dist", "vite-plugin", "index.original.js"),
join("dist", "vite-plugin", "index.js")
);
}
run(`npx tsc -p ${join("src", "vite-plugin", "tsconfig.json")}`);
if (
!fs
.readFileSync(join("dist", "vite-plugin", "index.js"))
.toString("utf8")
.includes("__nccwpck_require__")
) {
fs.cpSync(
join("dist", "vite-plugin", "index.js"),
join("dist", "vite-plugin", "index.original.js")
);
}
run(
`npx ncc build ${join("dist", "vite-plugin", "index.js")} --external prettier -o ${join(
"dist",
"ncc_out"
)}`
);
fs.readdirSync(join("dist", "ncc_out")).forEach(fileBasename => {
assert(!fileBasename.endsWith(".index.js"));
assert(!fileBasename.endsWith(".node"));
});
transformCodebase({
srcDirPath: join("dist", "ncc_out"),
destDirPath: join("dist", "vite-plugin"),
transformSourceCode: ({ fileRelativePath, sourceCode }) => {
assert(fileRelativePath === "index.js");
return { modifiedSourceCode: sourceCode };
}
});
fs.rmSync(join("dist", "ncc_out"), { recursive: true });
{
const dirBasename = "src";
const destDirPath = join("dist", dirBasename);
fs.rmSync(destDirPath, { recursive: true, force: true });
fs.cpSync(dirBasename, destDirPath, { recursive: true });
}
transformCodebase({
srcDirPath: join("stories"),
destDirPath: join("dist", "stories"),
transformSourceCode: ({ fileRelativePath, sourceCode }) => {
if (!fileRelativePath.endsWith(".stories.tsx")) {
return undefined;
}
return { modifiedSourceCode: sourceCode };
}
});
await createPublicKeycloakifyDevResourcesDir();
await createAccountV1Dir();
await downloadKeycloakifyLogging({
distDirPath: join(process.cwd(), "dist")
});
console.log(
chalk.green(`✓ built in ${((Date.now() - startTime) / 1000).toFixed(2)}s`)
);
})();
function patchDeprecatedBufferApiUsage(filePath: string) {
const before = fs.readFileSync(filePath).toString("utf8");
const after = before.replace(
`var buffer = new Buffer(toRead);`,
`var buffer = Buffer.allocUnsafe ? Buffer.allocUnsafe(toRead) : new Buffer(toRead);`
);
fs.writeFileSync(filePath, Buffer.from(after, "utf8"));
const hasBeenPatched = after !== before;
return { hasBeenPatched };
}

View File

@ -1,97 +0,0 @@
import * as fs from "fs";
import { join as pathJoin, basename as pathBasename, dirname as pathDirname } from "path";
import { assert } from "tsafe/assert";
import { run } from "../shared/run";
import { cacheDirPath as cacheDirPath_base } from "../shared/cacheDirPath";
export function vendorFrontendDependencies(params: { distDirPath: string }) {
const { distDirPath } = params;
const vendorDirPath = pathJoin(distDirPath, "tools", "vendor");
const cacheDirPath = pathJoin(cacheDirPath_base, "vendorFrontendDependencies");
const extraBundleFileBasenames = new Set<string>();
fs.readdirSync(vendorDirPath)
.filter(fileBasename => fileBasename.endsWith(".js"))
.map(fileBasename => pathJoin(vendorDirPath, fileBasename))
.forEach(filePath => {
{
const mapFilePath = `${filePath}.map`;
if (fs.existsSync(mapFilePath)) {
fs.unlinkSync(mapFilePath);
}
}
if (!fs.existsSync(cacheDirPath)) {
fs.mkdirSync(cacheDirPath, { recursive: true });
}
const webpackConfigJsFilePath = pathJoin(cacheDirPath, "webpack.config.js");
const webpackOutputDirPath = pathJoin(cacheDirPath, "webpack_output");
const webpackOutputFilePath = pathJoin(webpackOutputDirPath, "index.js");
fs.writeFileSync(
webpackConfigJsFilePath,
Buffer.from(
[
``,
`module.exports = {`,
` mode: 'production',`,
` entry: Buffer.from("${Buffer.from(filePath, "utf8").toString("base64")}", "base64").toString("utf8"),`,
` output: {`,
` path: Buffer.from("${Buffer.from(webpackOutputDirPath, "utf8").toString("base64")}", "base64").toString("utf8"),`,
` filename: '${pathBasename(webpackOutputFilePath)}',`,
` libraryTarget: 'module',`,
` },`,
` target: "web",`,
` module: {`,
` rules: [`,
` {`,
` test: /\.js$/,`,
` use: {`,
` loader: 'babel-loader',`,
` options: {`,
` presets: ['@babel/preset-env'],`,
` }`,
` }`,
` }`,
` ]`,
` },`,
` experiments: {`,
` outputModule: true`,
` }`,
`};`
].join("\n")
)
);
run(`npx webpack --config ${pathBasename(webpackConfigJsFilePath)}`, {
cwd: pathDirname(webpackConfigJsFilePath)
});
fs.readdirSync(webpackOutputDirPath)
.filter(fileBasename => !fileBasename.endsWith(".txt"))
.map(fileBasename => pathJoin(webpackOutputDirPath, fileBasename))
.forEach(bundleFilePath => {
assert(bundleFilePath.endsWith(".js"));
if (pathBasename(bundleFilePath) === "index.js") {
fs.renameSync(webpackOutputFilePath, filePath);
} else {
const bundleFileBasename = pathBasename(bundleFilePath);
assert(!extraBundleFileBasenames.has(bundleFileBasename));
extraBundleFileBasenames.add(bundleFileBasename);
fs.renameSync(
bundleFilePath,
pathJoin(pathDirname(filePath), bundleFileBasename)
);
}
});
fs.rmSync(webpackOutputDirPath, { recursive: true });
});
}

View File

@ -1,45 +0,0 @@
import { CONTAINER_NAME } from "../src/bin/shared/constants";
import child_process from "child_process";
import { SemVer } from "../src/bin/tools/SemVer";
import { dumpContainerConfig } from "../src/bin/start-keycloak/realmConfig/dumpContainerConfig";
import { cacheDirPath } from "./shared/cacheDirPath";
import { getThisCodebaseRootDirPath } from "../src/bin/tools/getThisCodebaseRootDirPath";
import { writeRealmJsonFile } from "../src/bin/start-keycloak/realmConfig/ParsedRealmJson";
import { join as pathJoin } from "path";
import chalk from "chalk";
(async () => {
const keycloakMajorVersionNumber = SemVer.parse(
child_process
.execSync(`docker inspect --format '{{.Config.Image}}' ${CONTAINER_NAME}`)
.toString("utf8")
.trim()
.split(":")[1]
).major;
const parsedRealmJson = await dumpContainerConfig({
buildContext: {
cacheDirPath
},
keycloakMajorVersionNumber,
realmName: "myrealm"
});
const realmJsonFilePath = pathJoin(
getThisCodebaseRootDirPath(),
"src",
"bin",
"start-keycloak",
"realmConfig",
"defaultConfig",
`realm-kc-${keycloakMajorVersionNumber}.json`
);
await writeRealmJsonFile({
parsedRealmJson,
realmJsonFilePath,
keycloakMajorVersionNumber
});
console.log(chalk.green(`Realm config dumped to ${realmJsonFilePath}`));
})();

View File

@ -1,200 +1,93 @@
import "minimal-polyfills/Object.fromEntries";
import * as fs from "fs";
import {
join as pathJoin,
relative as pathRelative,
dirname as pathDirname,
sep as pathSep
} from "path";
import { assert, type Equals } from "tsafe/assert";
import { same } from "evt/tools/inDepth";
import { join as pathJoin, relative as pathRelative, dirname as pathDirname, sep as pathSep } from "path";
import { crawl } from "../src/bin/tools/crawl";
import { downloadKeycloakDefaultTheme } from "./shared/downloadKeycloakDefaultTheme";
import { getThisCodebaseRootDirPath } from "../src/bin/tools/getThisCodebaseRootDirPath";
import { deepAssign } from "../src/tools/deepAssign";
import { THEME_TYPES } from "../src/bin/shared/constants";
import { transformCodebase } from "../src/bin/tools/transformCodebase";
import propertiesParser from "properties-parser";
import { downloadBuiltinKeycloakTheme } from "../src/bin/download-builtin-keycloak-theme";
import { getProjectRoot } from "../src/bin/tools/getProjectRoot";
import { getLogger } from "../src/bin/tools/logger";
if (require.main === module) {
generateI18nMessages();
}
// NOTE: To run without argument when we want to generate src/i18n/generated_kcMessages files,
// update the version array for generating for newer version.
async function generateI18nMessages() {
const thisCodebaseRootDirPath = getThisCodebaseRootDirPath();
//@ts-ignore
const propertiesParser = require("properties-parser");
const accountI18nDirPath = pathJoin(
thisCodebaseRootDirPath,
"src",
"account",
"i18n"
);
const isSilent = true;
if (fs.existsSync(accountI18nDirPath)) {
fs.rmSync(accountI18nDirPath, { recursive: true });
}
const logger = getLogger({ isSilent });
async function main() {
const keycloakVersion = "21.0.1";
const tmpDirPath = pathJoin(getProjectRoot(), "tmp_xImOef9dOd44");
fs.rmSync(tmpDirPath, { "recursive": true, "force": true });
await downloadBuiltinKeycloakTheme({
"projectDirPath": getProjectRoot(),
keycloakVersion,
"destDirPath": tmpDirPath
});
type Dictionary = { [idiomId: string]: string };
const record: { [themeType: string]: { [language: string]: Dictionary } } = {};
const record: { [typeOfPage: string]: { [language: string]: Dictionary } } = {};
for (const themeType of THEME_TYPES.filter(themeType => themeType !== "admin")) {
const { extractedDirPath } = await downloadKeycloakDefaultTheme({
keycloakVersionId: (() => {
switch (themeType) {
case "login":
return "FOR_LOGIN_THEME";
case "account":
return "FOR_ACCOUNT_MULTI_PAGE";
}
assert<Equals<typeof themeType, never>>();
})()
});
{
const baseThemeDirPath = pathJoin(tmpDirPath, "base");
const re = new RegExp(`^([^\\${pathSep}]+)\\${pathSep}messages\\${pathSep}messages_([^.]+).properties$`);
{
const baseThemeDirPath = pathJoin(extractedDirPath, "base");
const re = new RegExp(
`^([^\\${pathSep}]+)\\${pathSep}messages\\${pathSep}messages_([^.]+).properties$`
);
crawl({
"dirPath": baseThemeDirPath,
"returnedPathsType": "relative to dirPath"
}).forEach(filePath => {
const match = filePath.match(re);
crawl({
dirPath: baseThemeDirPath,
returnedPathsType: "relative to dirPath"
}).forEach(filePath => {
const match = filePath.match(re);
if (match === null) {
return;
}
const [, themeType_here, language] = match;
if (themeType_here !== themeType) {
return;
}
(record[themeType] ??= {})[language.replace(/_/g, "-")] =
Object.fromEntries(
Object.entries(
propertiesParser.parse(
fs
.readFileSync(pathJoin(baseThemeDirPath, filePath))
.toString("utf8")
) as Record<string, string>
)
.map(([key, value]) => [key, value.replace(/''/g, "'")])
.map(([key, value]) => [
key === "locale_pt_BR" ? "locale_pt-BR" : key,
value
])
.map(([key, value]) => [
key,
key === "termsText" ? "" : value
])
);
});
}
const recordForThemeType = record[themeType];
const languages = Object.keys(recordForThemeType);
const keycloakifyExtraMessages = (() => {
switch (themeType) {
case "login":
return keycloakifyExtraMessages_login;
case "account":
return keycloakifyExtraMessages_account;
if (match === null) {
return;
}
assert(false);
})();
/* Migration helper
const [, typeOfPage, language] = match;
console.log({ themeType });
{
const all = new Set<string>();
languages.forEach(languages => all.add(languages));
const currentlySupportedLanguages = Object.keys(keycloakifyExtraMessages);
currentlySupportedLanguages.forEach(languages => all.add(languages));
all.forEach(language => {
console.log([
`"${language}": `,
`isInLanguages: ${languages.includes(language)}`,
`isInKeycloakifyExtraMessages: ${currentlySupportedLanguages.includes(language)}`
].join(" "))
});
}
*/
assert(
same(languages, Object.keys(keycloakifyExtraMessages), {
takeIntoAccountArraysOrdering: false
})
);
deepAssign({
target: recordForThemeType,
source: keycloakifyExtraMessages
(record[typeOfPage] ??= {})[language.replace(/_/g, "-")] = Object.fromEntries(
Object.entries(propertiesParser.parse(fs.readFileSync(pathJoin(baseThemeDirPath, filePath)).toString("utf8"))).map(
([key, value]: any) => [key, value.replace(/''/g, "'")]
)
);
});
}
const messagesDirPath = pathJoin(
thisCodebaseRootDirPath,
"src",
themeType,
"i18n",
"messages_defaultSet"
);
fs.rmSync(tmpDirPath, { recursive: true, force: true });
if (!fs.existsSync(messagesDirPath)) {
fs.mkdirSync(messagesDirPath, { recursive: true });
Object.keys(record).forEach(themeType => {
const recordForPageType = record[themeType];
if (themeType !== "login" && themeType !== "account") {
return;
}
fs.writeFileSync(
pathJoin(messagesDirPath, "types.ts"),
Buffer.from(
[
``,
`export const languageTags = ${JSON.stringify(languages, null, 2)} as const;`,
``,
`export type LanguageTag = typeof languageTags[number];`,
``,
`export type MessageKey = keyof typeof import("./en")["default"];`,
``
].join("\n"),
"utf8"
)
);
const baseMessagesDirPath = pathJoin(getProjectRoot(), "src", themeType, "i18n", "baseMessages");
const languages = Object.keys(recordForPageType);
const generatedFileHeader = [
`//This code was automatically generated by running ${pathRelative(
thisCodebaseRootDirPath,
__filename
)}`,
"//PLEASE DO NOT EDIT MANUALLY"
`//This code was automatically generated by running ${pathRelative(getProjectRoot(), __filename)}`,
"//PLEASE DO NOT EDIT MANUALLY",
""
].join("\n");
languages.forEach(language => {
const filePath = pathJoin(messagesDirPath, `${language}.ts`);
const filePath = pathJoin(baseMessagesDirPath, `${language}.ts`);
fs.mkdirSync(pathDirname(filePath), { recursive: true });
fs.mkdirSync(pathDirname(filePath), { "recursive": true });
fs.writeFileSync(
filePath,
Buffer.from(
[
generatedFileHeader,
"",
"/* spell-checker: disable */",
`const messages= ${JSON.stringify(
recordForThemeType[language],
null,
2
)};`,
`const messages= ${JSON.stringify(recordForPageType[language], null, 2)};`,
"",
"export default messages;",
"/* spell-checker: enable */"
@ -203,551 +96,30 @@ async function generateI18nMessages() {
)
);
//console.log(`${filePath} wrote`);
logger.log(`${filePath} wrote`);
});
fs.writeFileSync(
pathJoin(messagesDirPath, "index.ts"),
pathJoin(baseMessagesDirPath, "index.ts"),
Buffer.from(
[
generatedFileHeader,
`import * as en from "./en";`,
"",
"export async function fetchMessages_defaultSet(currentLanguageTag: string) {",
" const { default: messages_defaultSet } = await (() => {",
"export async function getMessages(currentLanguageTag: string) {",
" const { default: messages } = await (() => {",
" switch (currentLanguageTag) {",
` case "en": return en;`,
...languages
.filter(language => language !== "en")
.map(
language =>
` case "${language}": return import("./${language}");`
),
...languages.map(language => ` case "${language}": return import("./${language}");`),
' default: return { "default": {} };',
" }",
" })();",
" return messages_defaultSet;",
" return messages;",
"}"
].join("\n"),
"utf8"
)
);
}
transformCodebase({
srcDirPath: pathJoin(thisCodebaseRootDirPath, "src", "login", "i18n"),
destDirPath: accountI18nDirPath,
transformSourceCode: ({ fileRelativePath, sourceCode }) => {
if (fileRelativePath.startsWith("messages_defaultSet")) {
return undefined;
}
return { modifiedSourceCode: sourceCode };
}
});
}
const keycloakifyExtraMessages_login: Record<
| "en"
| "ar"
| "ca"
| "cs"
| "da"
| "de"
| "el"
| "es"
| "fa"
| "fi"
| "fr"
| "hu"
| "it"
| "ja"
| "lt"
| "lv"
| "nl"
| "no"
| "pl"
| "pt"
| "pt-BR"
| "ru"
| "sk"
| "sv"
| "th"
| "tr"
| "uk"
| "ka"
| "zh-CN"
| "zh-TW",
Record<
| "shouldBeEqual"
| "shouldBeDifferent"
| "shouldMatchPattern"
| "mustBeAnInteger"
| "notAValidOption"
| "selectAnOption"
| "remove"
| "addValue"
| "languages",
string
>
> = {
en: {
shouldBeEqual: "{0} should be equal to {1}",
shouldBeDifferent: "{0} should be different to {1}",
shouldMatchPattern: "Pattern should match: `/{0}/`",
mustBeAnInteger: "Must be an integer",
notAValidOption: "Not a valid option",
selectAnOption: "Select an option",
remove: "Remove",
addValue: "Add value",
languages: "Languages"
},
/* spell-checker: disable */
ar: {
shouldBeEqual: "{0} يجب أن يكون مساويًا لـ {1}",
shouldBeDifferent: "{0} يجب أن يكون مختلفًا عن {1}",
shouldMatchPattern: "`/يجب أن يطابق النمط: `/{0}/",
mustBeAnInteger: "يجب أن يكون عددًا صحيحًا",
notAValidOption: "ليس خيارًا صالحًا",
selectAnOption: "اختر خيارًا",
remove: "إزالة",
addValue: "أضف قيمة",
languages: "اللغات"
},
ca: {
shouldBeEqual: "{0} hauria de ser igual a {1}",
shouldBeDifferent: "{0} hauria de ser diferent de {1}",
shouldMatchPattern: "El patró hauria de coincidir: `/{0}/`",
mustBeAnInteger: "Ha de ser un enter",
notAValidOption: "No és una opció vàlida",
selectAnOption: "Selecciona una opció",
remove: "Elimina",
addValue: "Afegeix valor",
languages: "Idiomes"
},
cs: {
shouldBeEqual: "{0} by měl být roven {1}",
shouldBeDifferent: "{0} by měl být odlišný od {1}",
shouldMatchPattern: "Vzor by měl odpovídat: `/{0}/`",
mustBeAnInteger: "Musí být celé číslo",
notAValidOption: "Není platná možnost",
selectAnOption: "Vyberte možnost",
remove: "Odstranit",
addValue: "Přidat hodnotu",
languages: "Jazyky"
},
da: {
shouldBeEqual: "{0} bør være lig med {1}",
shouldBeDifferent: "{0} bør være forskellig fra {1}",
shouldMatchPattern: "Mønsteret bør matche: `/{0}/`",
mustBeAnInteger: "Skal være et heltal",
notAValidOption: "Ikke en gyldig mulighed",
selectAnOption: "Vælg en mulighed",
remove: "Fjern",
addValue: "Tilføj værdi",
languages: "Sprog"
},
de: {
shouldBeEqual: "{0} sollte gleich {1} sein",
shouldBeDifferent: "{0} sollte sich von {1} unterscheiden",
shouldMatchPattern: "Muster sollte übereinstimmen: `/{0}/`",
mustBeAnInteger: "Muss eine ganze Zahl sein",
notAValidOption: "Keine gültige Option",
selectAnOption: "Wählen Sie eine Option",
remove: "Entfernen",
addValue: "Wert hinzufügen",
languages: "Sprachen"
},
el: {
shouldBeEqual: "Το {0} πρέπει να είναι ίσο με {1}",
shouldBeDifferent: "Το {0} πρέπει να διαφέρει από το {1}",
shouldMatchPattern: "Το πρότυπο πρέπει να ταιριάζει: `/{0}/`",
mustBeAnInteger: "Πρέπει να είναι ακέραιος",
notAValidOption: "Δεν είναι μια έγκυρη επιλογή",
selectAnOption: "Επιλέξτε μια επιλογή",
remove: "Αφαίρεση",
addValue: "Προσθήκη τιμής",
languages: "Γλώσσες"
},
es: {
shouldBeEqual: "{0} debería ser igual a {1}",
shouldBeDifferent: "{0} debería ser diferente a {1}",
shouldMatchPattern: "El patrón debería coincidir: `/{0}/`",
mustBeAnInteger: "Debe ser un número entero",
notAValidOption: "No es una opción válida",
selectAnOption: "Selecciona una opción",
remove: "Eliminar",
addValue: "Añadir valor",
languages: "Idiomas"
},
fa: {
shouldBeEqual: "{0} باید برابر باشد با {1}",
shouldBeDifferent: "{0} باید متفاوت باشد از {1}",
shouldMatchPattern: "الگو باید مطابقت داشته باشد: `/{0}/`",
mustBeAnInteger: "باید یک عدد صحیح باشد",
notAValidOption: "یک گزینه معتبر نیست",
selectAnOption: "یک گزینه انتخاب کنید",
remove: "حذف",
addValue: "افزودن مقدار",
languages: "زبان‌ها"
},
fi: {
shouldBeEqual: "{0} pitäisi olla yhtä suuri kuin {1}",
shouldBeDifferent: "{0} pitäisi olla erilainen kuin {1}",
shouldMatchPattern: "Mallin tulisi vastata: `/{0}/`",
mustBeAnInteger: "On oltava kokonaisluku",
notAValidOption: "Ei ole kelvollinen vaihtoehto",
selectAnOption: "Valitse vaihtoehto",
remove: "Poista",
addValue: "Lisää arvo",
languages: "Kielet"
},
fr: {
shouldBeEqual: "{0} devrait être égal à {1}",
shouldBeDifferent: "{0} devrait être différent de {1}",
shouldMatchPattern: "Le motif devrait correspondre: `/{0}/`",
mustBeAnInteger: "Doit être un entier",
notAValidOption: "Pas une option valide",
selectAnOption: "Sélectionnez une option",
remove: "Supprimer",
addValue: "Ajouter une valeur",
languages: "Langues"
},
hu: {
shouldBeEqual: "{0} egyenlő kell legyen {1}-vel",
shouldBeDifferent: "{0} különbözőnek kell lennie, mint {1}",
shouldMatchPattern: "A mintának egyeznie kell: `/{0}/`",
mustBeAnInteger: "Egész számnak kell lennie",
notAValidOption: "Nem érvényes opció",
selectAnOption: "Válasszon egy lehetőséget",
remove: "Eltávolítás",
addValue: "Érték hozzáadása",
languages: "Nyelvek"
},
it: {
shouldBeEqual: "{0} dovrebbe essere uguale a {1}",
shouldBeDifferent: "{0} dovrebbe essere diverso da {1}",
shouldMatchPattern: "Il modello dovrebbe corrispondere: `/{0}/`",
mustBeAnInteger: "Deve essere un numero intero",
notAValidOption: "Non è un'opzione valida",
selectAnOption: "Seleziona un'opzione",
remove: "Rimuovi",
addValue: "Aggiungi valore",
languages: "Lingue"
},
ja: {
shouldBeEqual: "{0} は {1} と等しい必要があります",
shouldBeDifferent: "{0} は {1} と異なる必要があります",
shouldMatchPattern: "パターンは一致する必要があります: `/{0}/`",
mustBeAnInteger: "整数である必要があります",
notAValidOption: "有効なオプションではありません",
selectAnOption: "オプションを選択",
remove: "削除",
addValue: "値を追加",
languages: "言語"
},
lt: {
shouldBeEqual: "{0} turėtų būti lygus {1}",
shouldBeDifferent: "{0} turėtų skirtis nuo {1}",
shouldMatchPattern: "Šablonas turėtų atitikti: `/{0}/`",
mustBeAnInteger: "Turi būti sveikasis skaičius",
notAValidOption: "Netinkama parinktis",
selectAnOption: "Pasirinkite parinktį",
remove: "Pašalinti",
addValue: "Pridėti reikšmę",
languages: "Kalbos"
},
lv: {
shouldBeEqual: "{0} jābūt vienādam ar {1}",
shouldBeDifferent: "{0} jābūt atšķirīgam no {1}",
shouldMatchPattern: "Mustrim jāsakrīt: `/{0}/`",
mustBeAnInteger: "Jābūt veselam skaitlim",
notAValidOption: "Nav derīga opcija",
selectAnOption: "Izvēlieties opciju",
remove: "Noņemt",
addValue: "Pievienot vērtību",
languages: "Valodas"
},
nl: {
shouldBeEqual: "{0} moet gelijk zijn aan {1}",
shouldBeDifferent: "{0} moet verschillen van {1}",
shouldMatchPattern: "Patroon moet overeenkomen: `/{0}/`",
mustBeAnInteger: "Moet een geheel getal zijn",
notAValidOption: "Geen geldige optie",
selectAnOption: "Selecteer een optie",
remove: "Verwijderen",
addValue: "Waarde toevoegen",
languages: "Talen"
},
no: {
shouldBeEqual: "{0} skal være lik {1}",
shouldBeDifferent: "{0} skal være forskjellig fra {1}",
shouldMatchPattern: "Mønsteret skal matche: `/{0}/`",
mustBeAnInteger: "Må være et heltall",
notAValidOption: "Ikke et gyldig alternativ",
selectAnOption: "Velg et alternativ",
remove: "Fjern",
addValue: "Legg til verdi",
languages: "Språk"
},
pl: {
shouldBeEqual: "{0} powinno być równe {1}",
shouldBeDifferent: "{0} powinno być różne od {1}",
shouldMatchPattern: "Wzór pow inien pasować: `/{0}/`",
mustBeAnInteger: "Musi być liczbą całkowitą",
notAValidOption: "Nieprawidłowa opcja",
selectAnOption: "Wybierz opcję",
remove: "Usuń",
addValue: "Dodaj wartość",
languages: "Języki"
},
pt: {
shouldBeEqual: "{0} deve ser igual a {1}",
shouldBeDifferent: "{0} deve ser diferente de {1}",
shouldMatchPattern: "O padrão deve corresponder: `/{0}/`",
mustBeAnInteger: "Deve ser um número inteiro",
notAValidOption: "Não é uma opção válida",
selectAnOption: "Selecione uma opção",
remove: "Remover",
addValue: "Adicionar valor",
languages: "Idiomas"
},
"pt-BR": {
shouldBeEqual: "{0} deve ser igual a {1}",
shouldBeDifferent: "{0} deve ser diferente de {1}",
shouldMatchPattern: "O padrão deve corresponder: `/{0}/`",
mustBeAnInteger: "Deve ser um número inteiro",
notAValidOption: "Não é uma opção válida",
selectAnOption: "Selecione uma opção",
remove: "Remover",
addValue: "Adicionar valor",
languages: "Idiomas"
},
ru: {
shouldBeEqual: "{0} должно быть равно {1}",
shouldBeDifferent: "{0} должно отличаться от {1}",
shouldMatchPattern: "Шаблон должен соответствовать: `/{0}/`",
mustBeAnInteger: "Должно быть целым числом",
notAValidOption: "Недопустимый вариант",
selectAnOption: "Выберите вариант",
remove: "Удалить",
addValue: "Добавить значение",
languages: "Языки"
},
sk: {
shouldBeEqual: "{0} by mal byť rovnaký ako {1}",
shouldBeDifferent: "{0} by mal byť odlišný od {1}",
shouldMatchPattern: "Vzor by mal zodpovedať: `/{0}/`",
mustBeAnInteger: "Musí byť celé číslo",
notAValidOption: "Nie je platná možnosť",
selectAnOption: "Vyberte možnosť",
remove: "Odstrániť",
addValue: "Pridať hodnotu",
languages: "Jazyky"
},
sv: {
shouldBeEqual: "{0} bör vara lika med {1}",
shouldBeDifferent: "{0} bör vara annorlunda än {1}",
shouldMatchPattern: "Mönstret bör matcha: `/{0}/`",
mustBeAnInteger: "Måste vara ett heltal",
notAValidOption: "Inte ett giltigt alternativ",
selectAnOption: "Välj ett alternativ",
remove: "Ta bort",
addValue: "Lägg till värde",
languages: "Språk"
},
th: {
shouldBeEqual: "{0} ควรเท่ากับ {1}",
shouldBeDifferent: "{0} ควรแตกต่างจาก {1}",
shouldMatchPattern: "รูปแบบควรตรงกับ: `/{0}/`",
mustBeAnInteger: "ต้องเป็นจำนวนเต็ม",
notAValidOption: "ไม่ใช่ตัวเลือกที่ถูกต้อง",
selectAnOption: "เลือกตัวเลือก",
remove: "ลบ",
addValue: "เพิ่มค่า",
languages: "ภาษา"
},
tr: {
shouldBeEqual: "{0} {1} eşit olmalıdır",
shouldBeDifferent: "{0} {1} farklı olmalıdır",
shouldMatchPattern: "Desen eşleşmelidir: `/{0}/`",
mustBeAnInteger: "Tam sayı olmalıdır",
notAValidOption: "Geçerli bir seçenek değil",
selectAnOption: "Bir seçenek seçin",
remove: "Kaldır",
addValue: "Değer ekle",
languages: "Diller"
},
uk: {
shouldBeEqual: "{0} повинно бути рівним {1}",
shouldBeDifferent: "{0} повинно відрізнятися від {1}",
shouldMatchPattern: "Шаблон повинен відповідати: `/{0}/`",
mustBeAnInteger: "Повинно бути цілим числом",
notAValidOption: "Не є дійсною опцією",
selectAnOption: "Виберіть опцію",
remove: "Видалити",
addValue: "Додати значення",
languages: "Мови"
},
ka: {
shouldBeEqual: "{0} უნდა იყოს ტოლი {1}-სთვის",
shouldBeDifferent: "{0} უნდა იყოს სხვა {1}-სთვის",
shouldMatchPattern: "შაბლონს უნდა ემთხვევა: `/{0}/`",
mustBeAnInteger: "უნდა იყოს მთელი რიცხვი",
notAValidOption: "არასწორი ვარიანტი",
selectAnOption: "აირჩიეთ ვარიანტი",
remove: "წაშალეთ",
addValue: "დაამატეთ მნიშვნელობა",
languages: "ენები"
},
"zh-CN": {
shouldBeEqual: "{0} 应该等于 {1}",
shouldBeDifferent: "{0} 应该不同于 {1}",
shouldMatchPattern: "模式应匹配: `/{0}/`",
mustBeAnInteger: "必须是整数",
notAValidOption: "不是有效选项",
selectAnOption: "选择一个选项",
remove: "移除",
addValue: "添加值",
languages: "语言"
},
"zh-TW": {
shouldBeEqual: "{0} 應該等於 {1}",
shouldBeDifferent: "{0} 應該不同於 {1}",
shouldMatchPattern: "模式應匹配: `/{0}/`",
mustBeAnInteger: "必須是整數",
notAValidOption: "不是有效選項",
selectAnOption: "選擇一個選項",
remove: "移除",
addValue: "添加值",
languages: "語言"
}
/* spell-checker: enable */
};
export const accountMultiPageSupportedLanguages = [
"en",
"ar",
"ca",
"cs",
"da",
"de",
"es",
"fi",
"fr",
"hu",
"it",
"ja",
"lt",
"lv",
"nl",
"no",
"pl",
"pt-BR",
"ru",
"sk",
"sv",
"tr",
"zh-CN"
] as const;
const keycloakifyExtraMessages_account: Record<
(typeof accountMultiPageSupportedLanguages)[number],
Record<"newPasswordSameAsOld" | "passwordConfirmNotMatch", string>
> = {
en: {
newPasswordSameAsOld: "New password must be different from the old one",
passwordConfirmNotMatch: "Password confirmation does not match"
},
/* spell-checker: disable */
ar: {
newPasswordSameAsOld: "يجب أن تكون كلمة المرور الجديدة مختلفة عن القديمة",
passwordConfirmNotMatch: "تأكيد كلمة المرور لا يتطابق"
},
ca: {
newPasswordSameAsOld: "La nova contrasenya ha de ser diferent de l'anterior",
passwordConfirmNotMatch: "La confirmació de la contrasenya no coincideix"
},
cs: {
newPasswordSameAsOld: "Nové heslo musí být odlišné od starého",
passwordConfirmNotMatch: "Potvrzení hesla se neshoduje"
},
da: {
newPasswordSameAsOld: "Det nye kodeord skal være forskelligt fra det gamle",
passwordConfirmNotMatch: "Adgangskodebekræftelse matcher ikke"
},
de: {
newPasswordSameAsOld: "Das neue Passwort muss sich vom alten unterscheiden",
passwordConfirmNotMatch: "Passwortbestätigung stimmt nicht überein"
},
es: {
newPasswordSameAsOld: "La nueva contraseña debe ser diferente de la anterior",
passwordConfirmNotMatch: "La confirmación de la contraseña no coincide"
},
fi: {
newPasswordSameAsOld: "Uusi salasana on oltava erilainen kuin vanha",
passwordConfirmNotMatch: "Salasanan vahvistus ei täsmää"
},
fr: {
newPasswordSameAsOld: "Le nouveau mot de passe doit être différent de l'ancien",
passwordConfirmNotMatch: "La confirmation du mot de passe ne correspond pas"
},
hu: {
newPasswordSameAsOld: "Az új jelszónak különböznie kell az előzőtől",
passwordConfirmNotMatch: "A jelszó megerősítése nem egyezik"
},
it: {
newPasswordSameAsOld:
"La nuova password deve essere diversa da quella precedente",
passwordConfirmNotMatch: "La conferma della password non corrisponde"
},
ja: {
newPasswordSameAsOld: "新しいパスワードは古いパスワードと異なる必要があります",
passwordConfirmNotMatch: "パスワード確認が一致しません"
},
lt: {
newPasswordSameAsOld: "Naujas slaptažodis turi skirtis nuo seno",
passwordConfirmNotMatch: "Slaptažodžio patvirtinimas neatitinka"
},
lv: {
newPasswordSameAsOld: "Jaunajam parolam jābūt atšķirīgam no vecā",
passwordConfirmNotMatch: "Paroles apstiprināšana neatbilst"
},
nl: {
newPasswordSameAsOld: "Het nieuwe wachtwoord moet verschillend zijn van het oude",
passwordConfirmNotMatch: "Wachtwoordbevestiging komt niet overeen"
},
no: {
newPasswordSameAsOld: "Det nye passordet må være forskjellig fra det gamle",
passwordConfirmNotMatch: "Passordbekreftelsen stemmer ikke"
},
pl: {
newPasswordSameAsOld: "Nowe hasło musi być inne niż stare",
passwordConfirmNotMatch: "Potwierdzenie hasła nie pasuje"
},
"pt-BR": {
newPasswordSameAsOld: "A nova senha deve ser diferente da antiga",
passwordConfirmNotMatch: "A confirmação da senha não corresponde"
},
ru: {
newPasswordSameAsOld: "Новый пароль должен отличаться от старого",
passwordConfirmNotMatch: "Подтверждение пароля не совпадает"
},
sk: {
newPasswordSameAsOld: "Nové heslo musí byť odlišné od starého",
passwordConfirmNotMatch: "Potvrdenie hesla sa nezhoduje"
},
sv: {
newPasswordSameAsOld: "Det nya lösenordet måste skilja sig från det gamla",
passwordConfirmNotMatch: "Lösenordsbekräftelsen matchar inte"
},
tr: {
newPasswordSameAsOld: "Yeni şifre eskisinden farklı olmalıdır",
passwordConfirmNotMatch: "Şifre doğrulama eşleşmiyor"
},
"zh-CN": {
newPasswordSameAsOld: "新密码必须与旧密码不同",
passwordConfirmNotMatch: "密码确认不匹配"
}
/* spell-checker: enable */
};
if (require.main === module) {
main();
}

View File

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

View File

@ -1,104 +1,79 @@
import { execSync } from "child_process";
import { join as pathJoin, relative as pathRelative } from "path";
import { getThisCodebaseRootDirPath } from "../src/bin/tools/getThisCodebaseRootDirPath";
import { getProjectRoot } from "../src/bin/tools/getProjectRoot";
import * as fs from "fs";
import * as os from "os";
const singletonDependencies: string[] = ["react", "@types/react"];
// For example [ "@emotion" ] it's more convenient than
// having to list every sub emotion packages (@emotion/css @emotion/utils ...)
// in singletonDependencies
const namespaceSingletonDependencies: string[] = [];
const rootDirPath = getThisCodebaseRootDirPath();
const commonThirdPartyDeps = [
...namespaceSingletonDependencies
.map(namespaceModuleName =>
fs
.readdirSync(pathJoin(rootDirPath, "node_modules", namespaceModuleName))
.map(submoduleName => `${namespaceModuleName}/${submoduleName}`)
)
.reduce((prev, curr) => [...prev, ...curr], []),
...singletonDependencies
];
const rootDirPath = getProjectRoot();
//NOTE: This is only required because of: https://github.com/garronej/ts-ci/blob/c0e207b9677523d4ec97fe672ddd72ccbb3c1cc4/README.md?plain=1#L54-L58
{
let modifiedPackageJsonContent = fs
.readFileSync(pathJoin(rootDirPath, "package.json"))
.toString("utf8");
fs.writeFileSync(
pathJoin(rootDirPath, "dist", "package.json"),
Buffer.from(
JSON.stringify(
(() => {
const packageJsonParsed = JSON.parse(fs.readFileSync(pathJoin(rootDirPath, "package.json")).toString("utf8"));
modifiedPackageJsonContent = (() => {
const o = JSON.parse(modifiedPackageJsonContent);
return {
...packageJsonParsed,
"main": packageJsonParsed["main"]?.replace(/^dist\//, ""),
"types": packageJsonParsed["types"]?.replace(/^dist\//, ""),
"module": packageJsonParsed["module"]?.replace(/^dist\//, ""),
"exports": !("exports" in packageJsonParsed)
? undefined
: Object.fromEntries(
Object.entries(packageJsonParsed["exports"]).map(([key, value]) => [
key,
(value as string).replace(/^\.\/dist\//, "./")
])
)
};
})(),
null,
2
),
"utf8"
)
);
delete o.files;
fs.cpSync(pathJoin(rootDirPath, "src"), pathJoin(rootDirPath, "dist", "src"), { "recursive": true });
return JSON.stringify(o, null, 2);
})();
const commonThirdPartyDeps = (() => {
// For example [ "@emotion" ] it's more convenient than
// having to list every sub emotion packages (@emotion/css @emotion/utils ...)
// in singletonDependencies
const namespaceSingletonDependencies: string[] = [];
modifiedPackageJsonContent = modifiedPackageJsonContent
.replace(/"dist\//g, '"')
.replace(/"\.\/dist\//g, '"./')
.replace(/"!dist\//g, '"!')
.replace(/"!\.\/dist\//g, '"!./');
return [
...namespaceSingletonDependencies
.map(namespaceModuleName =>
fs
.readdirSync(pathJoin(rootDirPath, "node_modules", namespaceModuleName))
.map(submoduleName => `${namespaceModuleName}/${submoduleName}`)
)
.reduce((prev, curr) => [...prev, ...curr], []),
...singletonDependencies
];
})();
modifiedPackageJsonContent = JSON.stringify(
{
...JSON.parse(modifiedPackageJsonContent),
version: `0.0.0-rc.${~~(Math.random() * 1000000)}`
},
null,
4
);
fs.writeFileSync(
pathJoin(rootDirPath, "dist", "package.json"),
Buffer.from(modifiedPackageJsonContent, "utf8")
);
}
const yarnGlobalDirPath = pathJoin(rootDirPath, ".yarn_home");
fs.rmSync(yarnGlobalDirPath, { recursive: true, force: true });
fs.rmSync(yarnGlobalDirPath, { "recursive": true, "force": true });
fs.mkdirSync(yarnGlobalDirPath);
const execYarnLink = (params: { targetModuleName?: string; cwd: string }) => {
const { targetModuleName, cwd } = params;
if (targetModuleName === undefined) {
const packageJsonFilePath = pathJoin(cwd, "package.json");
const packageJson = JSON.parse(
fs.readFileSync(packageJsonFilePath).toString("utf8")
);
delete packageJson["packageManager"];
fs.writeFileSync(
packageJsonFilePath,
Buffer.from(JSON.stringify(packageJson, null, 2))
);
}
const cmd = [
"yarn",
"link",
...(targetModuleName !== undefined ? [targetModuleName] : ["--no-bin-links"])
].join(" ");
const cmd = ["yarn", "link", ...(targetModuleName !== undefined ? [targetModuleName] : ["--no-bin-links"])].join(" ");
console.log(`$ cd ${pathRelative(rootDirPath, cwd) || "."} && ${cmd}`);
execSync(cmd, {
cwd,
env: {
"env": {
...process.env,
...(os.platform() === "win32"
? {
USERPROFILE: yarnGlobalDirPath,
LOCALAPPDATA: yarnGlobalDirPath
}
: { HOME: yarnGlobalDirPath })
"HOME": yarnGlobalDirPath
}
});
};
@ -114,9 +89,7 @@ const testAppPaths = (() => {
return testAppPath;
}
console.warn(
`Skipping ${testAppName} since it cant be found here: ${testAppPath}`
);
console.warn(`Skipping ${testAppName} since it cant be found here: ${testAppPath}`);
return undefined;
})
@ -128,54 +101,7 @@ if (testAppPaths.length === 0) {
process.exit(-1);
}
testAppPaths.forEach(testAppPath => {
const packageJsonFilePath = pathJoin(testAppPath, "package.json");
const packageJsonContent = fs.readFileSync(packageJsonFilePath);
const parsedPackageJson = JSON.parse(packageJsonContent.toString("utf8")) as {
scripts?: Record<string, string>;
};
let hasPostInstallOrPrepareScript = false;
if (parsedPackageJson.scripts !== undefined) {
for (const scriptName of ["postinstall", "prepare"]) {
if (parsedPackageJson.scripts[scriptName] === undefined) {
continue;
}
hasPostInstallOrPrepareScript = true;
delete parsedPackageJson.scripts[scriptName];
}
}
if (hasPostInstallOrPrepareScript) {
fs.writeFileSync(
packageJsonFilePath,
Buffer.from(JSON.stringify(parsedPackageJson, null, 2), "utf8")
);
}
const restorePackageJson = () => {
if (!hasPostInstallOrPrepareScript) {
return;
}
fs.writeFileSync(packageJsonFilePath, packageJsonContent);
};
try {
execSync("yarn install", { cwd: testAppPath });
} catch (error) {
restorePackageJson();
throw error;
}
restorePackageJson();
});
testAppPaths.forEach(testAppPath => execSync("yarn install", { "cwd": testAppPath }));
console.log("=== Linking common dependencies ===");
@ -188,54 +114,30 @@ commonThirdPartyDeps.forEach(commonThirdPartyDep => {
console.log(`${current}/${total} ${commonThirdPartyDep}`);
const localInstallPath = pathJoin(
...[
rootDirPath,
"node_modules",
...(commonThirdPartyDep.startsWith("@")
? commonThirdPartyDep.split("/")
: [commonThirdPartyDep])
]
...[rootDirPath, "node_modules", ...(commonThirdPartyDep.startsWith("@") ? commonThirdPartyDep.split("/") : [commonThirdPartyDep])]
);
execYarnLink({ cwd: localInstallPath });
execYarnLink({ "cwd": localInstallPath });
});
commonThirdPartyDeps.forEach(commonThirdPartyDep =>
testAppPaths.forEach(testAppPath =>
execYarnLink({
cwd: testAppPath,
targetModuleName: commonThirdPartyDep
"cwd": testAppPath,
"targetModuleName": commonThirdPartyDep
})
)
);
console.log("=== Linking in house dependencies ===");
execYarnLink({ cwd: pathJoin(rootDirPath, "dist") });
execYarnLink({ "cwd": pathJoin(rootDirPath, "dist") });
testAppPaths.forEach(testAppPath =>
execYarnLink({
cwd: testAppPath,
targetModuleName: JSON.parse(
fs.readFileSync(pathJoin(rootDirPath, "package.json")).toString("utf8")
)["name"]
"cwd": testAppPath,
"targetModuleName": JSON.parse(fs.readFileSync(pathJoin(rootDirPath, "package.json")).toString("utf8"))["name"]
})
);
testAppPaths.forEach(testAppPath => {
const { scripts = {} } = JSON.parse(
fs.readFileSync(pathJoin(testAppPath, "package.json")).toString("utf8")
) as {
scripts?: Record<string, string>;
};
for (const scriptName of ["postinstall", "prepare"]) {
if (scripts[scriptName] === undefined) {
continue;
}
execSync(`yarn run ${scriptName}`, { cwd: testAppPath });
}
});
export {};

View File

@ -1,88 +0,0 @@
import * as fs from "fs";
import { join as pathJoin, sep as pathSep } from "path";
import { run } from "./shared/run";
import cliSelect from "cli-select";
import { getThisCodebaseRootDirPath } from "../src/bin/tools/getThisCodebaseRootDirPath";
import chalk from "chalk";
import { removeNodeModules } from "./tools/removeNodeModules";
import { startRebuildOnSrcChange } from "./shared/startRebuildOnSrcChange";
(async () => {
const parentDirPath = pathJoin(getThisCodebaseRootDirPath(), "..");
const { starterName } = await (async () => {
const starterNames = fs
.readdirSync(parentDirPath)
.filter(
basename =>
basename.includes("starter") &&
basename.includes("keycloakify") &&
fs.statSync(pathJoin(parentDirPath, basename)).isDirectory()
);
if (starterNames.length === 0) {
console.log(
chalk.red(
`No starter found. Keycloakify Angular starter found in ${parentDirPath}`
)
);
process.exit(-1);
}
const starterName = await (async () => {
if (starterNames.length === 1) {
return starterNames[0];
}
console.log(chalk.cyan(`\nSelect a starter to link in:`));
const { value } = await cliSelect<string>({
values: starterNames.map(starterName => `..${pathSep}${starterName}`)
}).catch(() => {
process.exit(-1);
});
return value.split(pathSep)[1];
})();
return { starterName };
})();
const startTime = Date.now();
console.log(chalk.cyan(`\n\nLinking in ..${pathSep}${starterName}...`));
removeNodeModules({
nodeModulesDirPath: pathJoin(getThisCodebaseRootDirPath(), "node_modules")
});
fs.rmSync(pathJoin(getThisCodebaseRootDirPath(), "dist"), {
recursive: true,
force: true
});
fs.rmSync(pathJoin(getThisCodebaseRootDirPath(), ".yarn_home"), {
recursive: true,
force: true
});
run("yarn install");
run("yarn build");
const starterDirPath = pathJoin(parentDirPath, starterName);
removeNodeModules({
nodeModulesDirPath: pathJoin(starterDirPath, "node_modules")
});
run("yarn install", { cwd: starterDirPath });
run(`npx tsx ${pathJoin("scripts", "link-in-app.ts")} ${starterName}`);
const durationSeconds = Math.round((Date.now() - startTime) / 1000);
await new Promise(resolve => setTimeout(resolve, 1000));
startRebuildOnSrcChange();
console.log(chalk.green(`\n\nLinked in ${starterName} in ${durationSeconds}s`));
})();

View File

@ -1,9 +0,0 @@
import { join as pathJoin } from "path";
import { getThisCodebaseRootDirPath } from "../../src/bin/tools/getThisCodebaseRootDirPath";
export const cacheDirPath = pathJoin(
getThisCodebaseRootDirPath(),
"node_modules",
".cache",
"scripts"
);

View File

@ -1,357 +0,0 @@
import { relative as pathRelative } from "path";
import { downloadAndExtractArchive } from "../../src/bin/tools/downloadAndExtractArchive";
import { getProxyFetchOptions } from "../../src/bin/tools/fetchProxyOptions";
import { join as pathJoin } from "path";
import { assert, type Equals } from "tsafe/assert";
import { cacheDirPath } from "./cacheDirPath";
import { getThisCodebaseRootDirPath } from "../../src/bin/tools/getThisCodebaseRootDirPath";
const KEYCLOAK_VERSION = {
FOR_LOGIN_THEME: "25.0.4",
FOR_ACCOUNT_MULTI_PAGE: "21.1.2"
} as const;
export async function downloadKeycloakDefaultTheme(params: {
keycloakVersionId: keyof typeof KEYCLOAK_VERSION;
}) {
const { keycloakVersionId } = params;
const keycloakVersion = KEYCLOAK_VERSION[keycloakVersionId];
let kcNodeModulesKeepFilePaths: Set<string> | undefined = undefined;
let kcNodeModulesKeepFilePaths_lastAccountV1: Set<string> | undefined = undefined;
const { extractedDirPath } = await downloadAndExtractArchive({
url: `https://repo1.maven.org/maven2/org/keycloak/keycloak-themes/${keycloakVersion}/keycloak-themes-${keycloakVersion}.jar`,
cacheDirPath,
fetchOptions: getProxyFetchOptions({
npmConfigGetCwd: getThisCodebaseRootDirPath()
}),
uniqueIdOfOnArchiveFile: "extractOnlyRequiredFiles",
onArchiveFile: async params => {
const fileRelativePath = pathRelative("theme", params.fileRelativePath);
if (fileRelativePath.startsWith("..")) {
return;
}
const { readFile, writeFile } = params;
if (
!fileRelativePath.startsWith("base") &&
!fileRelativePath.startsWith("keycloak")
) {
return;
}
switch (keycloakVersion) {
case KEYCLOAK_VERSION.FOR_LOGIN_THEME:
if (
!fileRelativePath.startsWith(pathJoin("base", "login")) &&
!fileRelativePath.startsWith(pathJoin("keycloak", "login")) &&
!fileRelativePath.startsWith(pathJoin("keycloak", "common"))
) {
return;
}
if (fileRelativePath.endsWith(".ftl")) {
return;
}
break;
case KEYCLOAK_VERSION.FOR_ACCOUNT_MULTI_PAGE:
if (
!fileRelativePath.startsWith(pathJoin("base", "account")) &&
!fileRelativePath.startsWith(pathJoin("keycloak", "account")) &&
!fileRelativePath.startsWith(pathJoin("keycloak", "common"))
) {
return;
}
break;
default:
assert<Equals<typeof keycloakVersion, never>>(false);
}
last_account_v1_transformations: {
if (keycloakVersion !== KEYCLOAK_VERSION.FOR_ACCOUNT_MULTI_PAGE) {
break last_account_v1_transformations;
}
skip_web_modules: {
if (
!fileRelativePath.startsWith(
pathJoin("keycloak", "common", "resources", "web_modules")
)
) {
break skip_web_modules;
}
return;
}
skip_lib: {
if (
!fileRelativePath.startsWith(
pathJoin("keycloak", "common", "resources", "lib")
)
) {
break skip_lib;
}
return;
}
skip_node_modules: {
const nodeModulesRelativeDirPath = pathJoin(
"keycloak",
"common",
"resources",
"node_modules"
);
if (!fileRelativePath.startsWith(nodeModulesRelativeDirPath)) {
break skip_node_modules;
}
if (kcNodeModulesKeepFilePaths_lastAccountV1 === undefined) {
kcNodeModulesKeepFilePaths_lastAccountV1 = new Set([
pathJoin("patternfly", "dist", "css", "patternfly.min.css"),
pathJoin(
"patternfly",
"dist",
"css",
"patternfly-additions.min.css"
),
pathJoin(
"patternfly",
"dist",
"fonts",
"OpenSans-Regular-webfont.woff2"
),
pathJoin(
"patternfly",
"dist",
"fonts",
"OpenSans-Bold-webfont.woff2"
),
pathJoin(
"patternfly",
"dist",
"fonts",
"OpenSans-Light-webfont.woff2"
),
pathJoin(
"patternfly",
"dist",
"fonts",
"OpenSans-Semibold-webfont.woff2"
),
pathJoin(
"patternfly",
"dist",
"fonts",
"PatternFlyIcons-webfont.ttf"
),
pathJoin(
"patternfly",
"dist",
"fonts",
"PatternFlyIcons-webfont.woff"
)
]);
}
const fileRelativeToNodeModulesPath = fileRelativePath.substring(
nodeModulesRelativeDirPath.length + 1
);
if (
kcNodeModulesKeepFilePaths_lastAccountV1.has(
fileRelativeToNodeModulesPath
)
) {
break skip_node_modules;
}
return;
}
patch_account_css: {
if (
fileRelativePath !==
pathJoin("keycloak", "account", "resources", "css", "account.css")
) {
break patch_account_css;
}
await writeFile({
fileRelativePath,
modifiedData: Buffer.from(
(await readFile())
.toString("utf8")
.replace("top: -34px;", "top: -34px !important;"),
"utf8"
)
});
return;
}
}
skip_unused_resources: {
if (keycloakVersion !== KEYCLOAK_VERSION.FOR_LOGIN_THEME) {
break skip_unused_resources;
}
skip_node_modules: {
const nodeModulesRelativeDirPath = pathJoin(
"keycloak",
"common",
"resources",
"node_modules"
);
if (!fileRelativePath.startsWith(nodeModulesRelativeDirPath)) {
break skip_node_modules;
}
if (kcNodeModulesKeepFilePaths === undefined) {
kcNodeModulesKeepFilePaths = new Set([
pathJoin("@patternfly", "patternfly", "patternfly.min.css"),
pathJoin("patternfly", "dist", "css", "patternfly.min.css"),
pathJoin(
"patternfly",
"dist",
"css",
"patternfly-additions.min.css"
),
pathJoin(
"patternfly",
"dist",
"fonts",
"OpenSans-Regular-webfont.woff2"
),
pathJoin(
"patternfly",
"dist",
"fonts",
"OpenSans-Light-webfont.woff2"
),
pathJoin(
"patternfly",
"dist",
"fonts",
"OpenSans-Bold-webfont.woff2"
),
pathJoin(
"patternfly",
"dist",
"fonts",
"OpenSans-Bold-webfont.woff"
),
pathJoin(
"patternfly",
"dist",
"fonts",
"OpenSans-Bold-webfont.ttf"
),
pathJoin(
"patternfly",
"dist",
"fonts",
"fontawesome-webfont.woff2"
),
pathJoin(
"patternfly",
"dist",
"fonts",
"PatternFlyIcons-webfont.ttf"
),
pathJoin(
"patternfly",
"dist",
"fonts",
"PatternFlyIcons-webfont.woff"
),
pathJoin(
"patternfly",
"dist",
"fonts",
"OpenSans-Semibold-webfont.woff2"
),
pathJoin(
"patternfly",
"dist",
"fonts",
"OpenSans-SemiboldItalic-webfont.woff2"
),
pathJoin(
"patternfly",
"dist",
"fonts",
"OpenSans-SemiboldItalic-webfont.woff"
),
pathJoin(
"patternfly",
"dist",
"fonts",
"OpenSans-SemiboldItalic-webfont.ttf"
),
pathJoin("patternfly", "dist", "img", "bg-login.jpg"),
pathJoin("jquery", "dist", "jquery.min.js"),
pathJoin("rfc4648", "lib", "rfc4648.js")
]);
}
const fileRelativeToNodeModulesPath = fileRelativePath.substring(
nodeModulesRelativeDirPath.length + 1
);
if (kcNodeModulesKeepFilePaths.has(fileRelativeToNodeModulesPath)) {
break skip_node_modules;
}
return;
}
skip_vendor: {
if (
!fileRelativePath.startsWith(
pathJoin("keycloak", "common", "resources", "vendor")
)
) {
break skip_vendor;
}
return;
}
skip_rollup_config: {
if (
fileRelativePath !==
pathJoin("keycloak", "common", "resources", "rollup.config.js")
) {
break skip_rollup_config;
}
return;
}
skip_package_json: {
if (
fileRelativePath !==
pathJoin("keycloak", "common", "resources", "package.json")
) {
break skip_package_json;
}
return;
}
}
await writeFile({ fileRelativePath });
}
});
return { extractedDirPath };
}

View File

@ -1,8 +0,0 @@
import * as child_process from "child_process";
import chalk from "chalk";
export function run(command: string, options?: { cwd: string }) {
console.log(chalk.grey(`$ ${command}`));
child_process.execSync(command, { stdio: "inherit", ...options });
}

View File

@ -1,40 +0,0 @@
import * as child_process from "child_process";
import { waitForDebounceFactory } from "powerhooks/tools/waitForDebounce";
import chokidar from "chokidar";
import * as runExclusive from "run-exclusive";
import { Deferred } from "evt/tools/Deferred";
import chalk from "chalk";
export function startRebuildOnSrcChange() {
const { waitForDebounce } = waitForDebounceFactory({ delay: 400 });
const runYarnBuild = runExclusive.build(async () => {
console.log(chalk.green("Running `yarn build`"));
const dCompleted = new Deferred<void>();
const child = child_process.spawn("yarn", ["build"], { shell: true });
child.stdout.on("data", data => process.stdout.write(data));
child.stderr.on("data", data => process.stderr.write(data));
child.on("exit", () => dCompleted.resolve());
await dCompleted.pr;
console.log("\n\n");
});
console.log(chalk.green("Watching for changes in src/"));
chokidar
.watch(["src", "stories"], { ignoreInitial: true })
.on("all", async (event, path) => {
console.log(chalk.bold(`${event}: ${path}`));
await waitForDebounce();
runYarnBuild();
});
}

View File

@ -1,21 +0,0 @@
import * as child_process from "child_process";
import { startRebuildOnSrcChange } from "./shared/startRebuildOnSrcChange";
import { run } from "./shared/run";
(async () => {
run("yarn build");
{
const child = child_process.spawn("npx", ["start-storybook", "-p", "6006"], {
shell: true
});
child.stdout.on("data", data => process.stdout.write(data));
child.stderr.on("data", data => process.stderr.write(data));
child.on("exit", process.exit.bind(process));
}
startRebuildOnSrcChange();
})();

View File

@ -0,0 +1,29 @@
import { execSync } from "child_process";
import { existsSync, readFileSync, rmSync, writeFileSync } from "fs";
import path from "path";
const testDir = "keycloakify_starter_test";
if (existsSync(path.join(process.cwd(), testDir))) {
rmSync(path.join(process.cwd(), testDir), { recursive: true });
}
// Build and link package
execSync("yarn build");
const pkgJSON = JSON.parse(readFileSync(path.join(process.cwd(), "package.json")).toString("utf8"));
pkgJSON.main = "./index.js";
pkgJSON.types = "./index.d.ts";
pkgJSON.scripts.prepare = undefined;
writeFileSync(path.join(process.cwd(), "dist", "package.json"), JSON.stringify(pkgJSON));
// Wrapped in a try/catch because unlink errors if the package isn't linked
try {
execSync("yarn unlink");
} catch {}
execSync("yarn link", { "cwd": path.join(process.cwd(), "dist") });
// Clone latest keycloakify-starter and link to keycloakify output
execSync(`git clone https://github.com/keycloakify/keycloakify-starter.git ${testDir}`);
execSync("yarn install", { "cwd": path.join(process.cwd(), testDir) });
execSync("yarn link keycloakify", { "cwd": path.join(process.cwd(), testDir) });
//Ensure keycloak theme can be built
execSync("yarn build-keycloak-theme", { "cwd": path.join(process.cwd(), testDir) });

View File

@ -1,27 +0,0 @@
import * as fs from "fs";
import { crawl } from "../../src/bin/tools/crawl";
export function removeNodeModules(params: { nodeModulesDirPath: string }) {
const { nodeModulesDirPath } = params;
try {
fs.rmSync(nodeModulesDirPath, { recursive: true, force: true });
} catch {
// NOTE: This is a workaround for windows
// we can't remove locked executables.
crawl({
dirPath: nodeModulesDirPath,
returnedPathsType: "absolute"
}).forEach(filePath => {
try {
fs.rmSync(filePath, { force: true });
} catch (error) {
if (filePath.endsWith(".exe")) {
return;
}
throw error;
}
});
}
}

View File

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

View File

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

26
src/account/Fallback.tsx Normal file
View File

@ -0,0 +1,26 @@
import { lazy, Suspense } from "react";
import type { PageProps } from "keycloakify/account/pages/PageProps";
import type { I18n } from "keycloakify/account/i18n";
import type { KcContext } from "./kcContext";
import { assert, type Equals } from "tsafe/assert";
const Password = lazy(() => import("keycloakify/account/pages/Password"));
const Account = lazy(() => import("keycloakify/account/pages/Account"));
export default function Fallback(props: PageProps<KcContext, I18n>) {
const { kcContext, ...rest } = props;
return (
<Suspense>
{(() => {
switch (kcContext.pageId) {
case "password.ftl":
return <Password kcContext={kcContext} {...rest} />;
case "account.ftl":
return <Account kcContext={kcContext} {...rest} />;
}
assert<Equals<typeof kcContext, never>>(false);
})()}
</Suspense>
);
}

View File

@ -1,310 +0,0 @@
import type { ThemeType, AccountThemePageId } from "keycloakify/bin/shared/constants";
import type { ValueOf } from "keycloakify/tools/ValueOf";
import { assert } from "tsafe/assert";
import type { Equals } from "tsafe";
export type ExtendKcContext<
KcContextExtension extends { properties?: Record<string, string | undefined> },
KcContextExtensionPerPage extends Record<string, Record<string, unknown>>
> = ValueOf<{
[PageId in keyof KcContextExtensionPerPage | KcContext["pageId"]]: Extract<
KcContext,
{ pageId: PageId }
> extends never
? KcContext.Common &
KcContextExtension & {
pageId: PageId;
} & KcContextExtensionPerPage[PageId]
: Extract<KcContext, { pageId: PageId }> &
KcContextExtension &
KcContextExtensionPerPage[PageId];
}>;
export type KcContext =
| KcContext.Password
| KcContext.Account
| KcContext.Sessions
| KcContext.Totp
| KcContext.Applications
| KcContext.Log
| KcContext.FederatedIdentity;
export declare namespace KcContext {
export type Common = {
themeVersion: string;
keycloakifyVersion: string;
themeType: "account";
themeName: string;
locale?: {
supported: {
url: string;
label: string;
languageTag: string;
}[];
currentLanguageTag: string;
};
url: {
accountUrl: string;
passwordUrl: string;
totpUrl: string;
socialUrl: string;
sessionsUrl: string;
applicationsUrl: string;
logUrl: string;
logoutUrl: string;
resourceUrl: string;
resourcesCommonPath: string;
resourcesPath: string;
/** @deprecated, not present in recent keycloak version apparently, use kcContext.referrer instead */
referrerURI?: string;
getLogoutUrl: () => string;
};
features: {
passwordUpdateSupported: boolean;
identityFederation: boolean;
log: boolean;
authorization: boolean;
};
realm: {
internationalizationEnabled: boolean;
userManagedAccessAllowed: boolean;
};
// Present only if redirected to account page with ?referrer=xxx&referrer_uri=http...
message?: {
type: "success" | "warning" | "error" | "info";
summary: string;
};
referrer?: {
url: string; // The url of the App
name: string; // Client id
};
messagesPerField: {
/**
* Return text if message for given field exists. Useful eg. to add css styles for fields with message.
*
* @param fieldName to check for
* @param text to return
* @return text if message exists for given field, else undefined
*/
printIfExists: <T extends string>(
fieldName: string,
text: T
) => T | undefined;
/**
* Check if exists error message for given fields
*
* @param fields
* @return boolean
*/
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: {
email?: string;
firstName: string;
lastName?: string;
username?: string;
};
properties: {};
"x-keycloakify": {
messages: Record<string, string>;
};
};
export type Password = Common & {
pageId: "password.ftl";
password: {
passwordSet: boolean;
};
stateChecker: string;
};
export type Account = Common & {
pageId: "account.ftl";
url: {
accountUrl: string;
};
realm: {
registrationEmailAsUsername: boolean;
editUsernameAllowed: boolean;
};
stateChecker: string;
};
export type Sessions = Common & {
pageId: "sessions.ftl";
sessions: {
sessions: {
expires: string;
clients: string[];
ipAddress: string;
started: string;
lastAccess: string;
id: string;
}[];
};
stateChecker: string;
};
export type Totp = Common & {
pageId: "totp.ftl";
totp: {
enabled: boolean;
totpSecretEncoded: string;
qrUrl: string;
policy: {
algorithm: "HmacSHA1" | "HmacSHA256" | "HmacSHA512";
digits: number;
lookAheadWindow: number;
getAlgorithmKey: () => string;
} & (
| {
type: "totp";
period: number;
}
| {
type: "hotp";
initialCounter: number;
}
);
supportedApplications: string[];
totpSecretQrCode: string;
manualUrl: string;
totpSecret: string;
otpCredentials: { id: string; userLabel: string }[];
};
mode?: "qr" | "manual" | undefined | null;
isAppInitiatedAction: boolean;
stateChecker: string;
};
export type Applications = Common & {
pageId: "applications.ftl";
features: {
log: boolean;
identityFederation: boolean;
authorization: boolean;
passwordUpdateSupported: boolean;
};
stateChecker: string;
applications: {
applications: {
realmRolesAvailable: {
name: string;
description: string;
compositesStream?: Record<string, unknown>;
clientRole?: boolean;
composite?: boolean;
id?: string;
containerId?: string;
attributes?: Record<string, unknown>;
}[];
resourceRolesAvailable: Record<
string,
{
roleName: string;
roleDescription?: string;
clientName: string;
clientId: string;
}[]
>;
additionalGrants: string[];
clientScopesGranted: string[];
effectiveUrl?: string;
client: {
alwaysDisplayInConsole: boolean;
attributes: Record<string, unknown>;
authenticationFlowBindingOverrides: Record<string, unknown>;
baseUrl?: string;
bearerOnly: boolean;
clientAuthenticatorType: string;
clientId: string;
consentRequired: boolean;
consentScreenText: string;
description: string;
directAccessGrantsEnabled: boolean;
displayOnConsentScreen: boolean;
dynamicScope: boolean;
enabled: boolean;
frontchannelLogout: boolean;
fullScopeAllowed: boolean;
id: string;
implicitFlowEnabled: boolean;
includeInTokenScope: boolean;
managementUrl: string;
name?: string;
nodeReRegistrationTimeout: string;
notBefore: string;
protocol: string;
protocolMappersStream: Record<string, unknown>;
publicClient: boolean;
realm: Record<string, unknown>;
realmScopeMappingsStream: Record<string, unknown>;
redirectUris: string[];
registeredNodes: Record<string, unknown>;
rolesStream: Record<string, unknown>;
rootUrl?: string;
scopeMappingsStream: Record<string, unknown>;
secret: string;
serviceAccountsEnabled: boolean;
standardFlowEnabled: boolean;
surrogateAuthRequired: boolean;
webOrigins: string[];
};
}[];
};
};
export type Log = Common & {
pageId: "log.ftl";
log: {
events: {
date: string | number | Date;
event: string;
ipAddress: string;
client: string;
details: { value: string; key: string }[];
}[];
};
};
export type FederatedIdentity = Common & {
pageId: "federatedIdentity.ftl";
stateChecker: string;
federatedIdentity: {
identities: {
providerId: string;
displayName: string;
userName: string;
connected: boolean;
}[];
removeLinkPossible: boolean;
};
};
}
{
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>();

View File

@ -1,69 +0,0 @@
import type { ExtendKcContext, KcContext as KcContextBase } from "./KcContext";
import type { AccountThemePageId } from "keycloakify/bin/shared/constants";
import type { DeepPartial } from "keycloakify/tools/DeepPartial";
import { deepAssign } from "keycloakify/tools/deepAssign";
import { structuredCloneButFunctions } from "keycloakify/tools/structuredCloneButFunctions";
import { kcContextMocks, kcContextCommonMock } from "./kcContextMocks";
import { exclude } from "tsafe/exclude";
export function createGetKcContextMock<
KcContextExtension extends { properties?: Record<string, string | undefined> },
KcContextExtensionPerPage extends Record<`${string}.ftl`, Record<string, unknown>>
>(params: {
kcContextExtension: KcContextExtension;
kcContextExtensionPerPage: KcContextExtensionPerPage;
overrides?: DeepPartial<KcContextExtension & KcContextBase.Common>;
overridesPerPage?: {
[PageId in AccountThemePageId | keyof KcContextExtensionPerPage]?: DeepPartial<
Extract<
ExtendKcContext<KcContextExtension, KcContextExtensionPerPage>,
{ pageId: PageId }
>
>;
};
}) {
const {
kcContextExtension,
kcContextExtensionPerPage,
overrides: overrides_global,
overridesPerPage: overridesPerPage_global
} = params;
type KcContext = ExtendKcContext<KcContextExtension, KcContextExtensionPerPage>;
function getKcContextMock<
PageId extends AccountThemePageId | keyof KcContextExtensionPerPage
>(params: {
pageId: PageId;
overrides?: DeepPartial<Extract<KcContext, { pageId: PageId }>>;
}): Extract<KcContext, { pageId: PageId }> {
const { pageId, overrides } = params;
const kcContextMock = structuredCloneButFunctions(
kcContextMocks.find(kcContextMock => kcContextMock.pageId === pageId) ?? {
...kcContextCommonMock,
pageId
}
);
[
kcContextExtension,
kcContextExtensionPerPage[pageId],
overrides_global,
overridesPerPage_global?.[pageId],
overrides
]
.filter(exclude(undefined))
.forEach(overrides =>
deepAssign({
target: kcContextMock,
source: overrides
})
);
// @ts-expect-error
return kcContextMock;
}
return { getKcContextMock };
}

View File

@ -1,2 +0,0 @@
export type { ExtendKcContext, KcContext } from "./KcContext";
export { createGetKcContextMock } from "./getKcContextMock";

View File

@ -1,213 +0,0 @@
import "keycloakify/tools/Object.fromEntries";
import { WELL_KNOWN_DIRECTORY_BASE_NAME } from "keycloakify/bin/shared/constants";
import { id } from "tsafe/id";
import type { KcContext } from "./KcContext";
import { BASE_URL } from "keycloakify/lib/BASE_URL";
import { assert, type Equals } from "tsafe/assert";
import type { LanguageTag } from "keycloakify/account/i18n/messages_defaultSet/types";
const resourcesPath = `${BASE_URL}${WELL_KNOWN_DIRECTORY_BASE_NAME.KEYCLOAKIFY_DEV_RESOURCES}/account`;
export const kcContextCommonMock: KcContext.Common = {
themeVersion: "0.0.0",
keycloakifyVersion: "0.0.0",
themeType: "account",
themeName: "my-theme-name",
url: {
resourcesPath,
resourcesCommonPath: `${resourcesPath}/${WELL_KNOWN_DIRECTORY_BASE_NAME.RESOURCES_COMMON}`,
resourceUrl: "#",
accountUrl: "#",
applicationsUrl: "#",
logoutUrl: "#",
getLogoutUrl: () => "#",
logUrl: "#",
passwordUrl: "#",
sessionsUrl: "#",
socialUrl: "#",
totpUrl: "#"
},
realm: {
internationalizationEnabled: true,
userManagedAccessAllowed: true
},
messagesPerField: {
printIfExists: () => {
return undefined;
},
existsError: () => false,
get: key => `Fake error for ${key}`,
exists: () => false
},
locale: {
supported: (
[
/* spell-checker: disable */
["de", "Deutsch"],
["no", "Norsk"],
["ru", "Русский"],
["sv", "Svenska"],
["pt-BR", "Português (Brasil)"],
["lt", "Lietuvių"],
["en", "English"],
["it", "Italiano"],
["fr", "Français"],
["zh-CN", "中文简体"],
["es", "Español"],
["cs", "Čeština"],
["ja", "日本語"],
["sk", "Slovenčina"],
["pl", "Polski"],
["ca", "Català"],
["nl", "Nederlands"],
["tr", "Türkçe"],
["ar", "العربية"],
["da", "Dansk"],
["fi", "Suomi"],
["hu", "Magyar"],
["lv", "Latviešu"]
/* spell-checker: enable */
] as const
).map(([languageTag, label]) => {
{
type Got = typeof languageTag;
type Expected = LanguageTag;
type Missing = Exclude<Expected, Got>;
type Unexpected = Exclude<Got, Expected>;
assert<Equals<Missing, never>>;
assert<Equals<Unexpected, never>>;
}
return {
languageTag,
label,
url: "https://gist.github.com/garronej/52baaca1bb925f2296ab32741e062b8e"
} as const;
}),
currentLanguageTag: "en"
},
features: {
authorization: true,
identityFederation: true,
log: true,
passwordUpdateSupported: true
},
referrer: undefined,
account: {
firstName: "john",
lastName: "doe",
email: "john.doe@code.gouv.fr",
username: "doe_j"
},
properties: {},
"x-keycloakify": {
messages: {}
}
};
export const kcContextMocks: KcContext[] = [
id<KcContext.Password>({
...kcContextCommonMock,
pageId: "password.ftl",
password: {
passwordSet: true
},
stateChecker: "state checker"
}),
id<KcContext.Account>({
...kcContextCommonMock,
pageId: "account.ftl",
url: {
...kcContextCommonMock.url,
referrerURI: "#",
accountUrl: "#"
},
realm: {
...kcContextCommonMock.realm,
registrationEmailAsUsername: true,
editUsernameAllowed: true
},
stateChecker: ""
}),
id<KcContext.Sessions>({
...kcContextCommonMock,
pageId: "sessions.ftl",
sessions: {
sessions: [
{
ipAddress: "127.0.0.1",
started: new Date().toString(),
lastAccess: new Date().toString(),
expires: new Date().toString(),
clients: ["Chrome", "Firefox"],
id: "f8951177-817d-4a70-9c02-86d3c170fe51"
}
]
},
stateChecker: "g6WB1FaYnKotTkiy7ZrlxvFztSqS0U8jvHsOOOb2z4g"
}),
id<KcContext.Totp>({
...kcContextCommonMock,
pageId: "totp.ftl",
totp: {
enabled: true,
totpSecretEncoded: "KVVF G2BY N4YX S6LB IUYT K2LH IFYE 4SBV",
qrUrl: "#",
totpSecretQrCode:
"iVBORw0KGgoAAAANSUhEUgAAAPYAAAD2AQAAAADNaUdlAAACM0lEQVR4Xu3OIZJgOQwDUDFd2UxiurLAVnnbHw4YGDKtSiWOn4Gxf81//7r/+q8b4HfLGBZDK9d85NmNR+sB42sXvOYrN5P1DcgYYFTGfOlbzE8gzwy3euweGizw7cfdl34/GRhlkxjKNV+5AebPXPORX1JuB9x8ZfbyyD2y1krWAKsbMq1HnqQDaLfa77p4+MqvzEGSqvSAD/2IHW2yHaigR9tX3m8dDIYGcNf3f+gDpVBZbZU77zyJ6Rlcy+qoTMG887KAPD9hsh6a1Sv3gJUHGHUAxSMzj7zqDDe7Phmt2eG+8UsMxjRGm816MAO+8VMl1R1jGHOrZB/5Zo/WXAPgxixm9Mo96vDGrM1eOto8c4Ax4wF437mifOXlpiPzCnN7Y9l95NnEMxgMY9AAGA8fucH14Y1aVb6N/cqrmyh0BVht7k1e+bU8LK0Cg5vmVq9c5vHIjOfqxDIfeTraNVTwewa4wVe+SW5N+uP1qACeudUZbqGOfA6VZV750Noq2Xx3kpveV44ZelSV1V7KFHzkWyVrrlUwG0Pl9pWnoy3vsQoME6vKI69i5osVgwWzHT7zjmJtMcNUSVn1oYMd7ZodbgowZl45VG0uVuLPUr1yc79uaQBag/mqR34xhlWyHm1prplHboCWdZ4TeZjsK8+dI+jbz1C5hl65mcpgB5dhcj8+dGO+0Ko68+lD37JDD83dpDLzzK+TrQyaVwGj6pUboGV+7+AyN8An/pf84/7rv/4/1l4OCc/1BYMAAAAASUVORK5CYII=",
manualUrl: "#",
totpSecret: "G4nsI8lQagRMUchH8jEG",
otpCredentials: [],
supportedApplications: [
"totpAppFreeOTPName",
"totpAppMicrosoftAuthenticatorName",
"totpAppGoogleName"
],
policy: {
algorithm: "HmacSHA1",
digits: 6,
lookAheadWindow: 1,
type: "totp",
period: 30,
getAlgorithmKey: () => "SHA1"
}
},
mode: "qr",
isAppInitiatedAction: false,
stateChecker: ""
}),
id<KcContext.Log>({
...kcContextCommonMock,
pageId: "log.ftl",
log: {
events: [
{
date: "2/21/2024, 1:28:39 PM",
event: "login",
ipAddress: "172.17.0.1",
client: "security-admin-console",
details: [{ key: "openid-connect", value: "admin" }]
}
]
}
}),
id<KcContext.FederatedIdentity>({
...kcContextCommonMock,
stateChecker: "",
pageId: "federatedIdentity.ftl",
federatedIdentity: {
identities: [
{
providerId: "keycloak-oidc",
displayName: "keycloak-oidc",
userName: "John",
connected: true
}
],
removeLinkPossible: true
}
})
];

View File

@ -1,39 +1,32 @@
import { useEffect } from "react";
import { clsx } from "keycloakify/tools/clsx";
import { kcSanitize } from "keycloakify/lib/kcSanitize";
import { getKcClsx } from "keycloakify/account/lib/kcClsx";
import { useSetClassName } from "keycloakify/tools/useSetClassName";
import { useInitialize } from "keycloakify/account/Template.useInitialize";
import type { TemplateProps } from "keycloakify/account/TemplateProps";
import { usePrepareTemplate } from "keycloakify/lib/usePrepareTemplate";
import { type TemplateProps } from "keycloakify/account/TemplateProps";
import { useGetClassName } from "keycloakify/account/lib/useGetClassName";
import type { KcContext } from "./kcContext";
import type { I18n } from "./i18n";
import type { KcContext } from "./KcContext";
import { assert } from "keycloakify/tools/assert";
export default function Template(props: TemplateProps<KcContext, I18n>) {
const { kcContext, i18n, doUseDefaultCss, active, classes, children } = props;
const { kcClsx } = getKcClsx({ doUseDefaultCss, classes });
const { getClassName } = useGetClassName({ doUseDefaultCss, classes });
const { msg, msgStr, currentLanguage, enabledLanguages } = i18n;
const { msg, changeLocale, labelBySupportedLanguageTag, currentLanguageTag } = i18n;
const { url, features, realm, message, referrer } = kcContext;
const { locale, url, features, realm, message, referrer } = kcContext;
useEffect(() => {
document.title = msgStr("accountManagementTitle");
}, []);
useSetClassName({
qualifiedName: "html",
className: kcClsx("kcHtmlClass")
const { isReady } = usePrepareTemplate({
"doFetchDefaultThemeResources": doUseDefaultCss,
"styles": [
`${url.resourcesCommonPath}/node_modules/patternfly/dist/css/patternfly.min.css`,
`${url.resourcesCommonPath}/node_modules/patternfly/dist/css/patternfly-additions.min.css`,
`${url.resourcesPath}/css/account.css`
],
"htmlClassName": getClassName("kcHtmlClass"),
"bodyClassName": clsx("admin-console", "user", getClassName("kcBodyClass"))
});
useSetClassName({
qualifiedName: "body",
className: clsx("admin-console", "user", kcClsx("kcBodyClass"))
});
const { isReadyToRender } = useInitialize({ kcContext, doUseDefaultCss });
if (!isReadyToRender) {
if (!isReady) {
return null;
}
@ -49,16 +42,20 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
<div className="navbar-collapse navbar-collapse-1">
<div className="container">
<ul className="nav navbar-nav navbar-utility">
{enabledLanguages.length > 1 && (
{realm.internationalizationEnabled && (assert(locale !== undefined), true) && locale.supported.length > 1 && (
<li>
<div className="kc-dropdown" id="kc-locale-dropdown">
{/* eslint-disable-next-line jsx-a11y/anchor-is-valid */}
<a href="#" id="kc-current-locale-link">
{currentLanguage.label}
{labelBySupportedLanguageTag[currentLanguageTag]}
</a>
<ul>
{enabledLanguages.map(({ languageTag, label, href }) => (
{locale.supported.map(({ languageTag }) => (
<li key={languageTag} className="kc-dropdown-item">
<a href={href}>{label}</a>
{/* eslint-disable-next-line jsx-a11y/anchor-is-valid */}
<a href="#" onClick={() => changeLocale(languageTag)}>
{labelBySupportedLanguageTag[languageTag]}
</a>
</li>
))}
</ul>
@ -124,12 +121,7 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
<div className={clsx("alert", `alert-${message.type}`)}>
{message.type === "success" && <span className="pficon pficon-ok"></span>}
{message.type === "error" && <span className="pficon pficon-error-circle-o"></span>}
<span
className="kc-feedback-text"
dangerouslySetInnerHTML={{
__html: kcSanitize(message.summary)
}}
/>
<span className="kc-feedback-text">{message.summary}</span>
</div>
)}

View File

@ -1,35 +0,0 @@
import { assert } from "keycloakify/tools/assert";
import { useInsertLinkTags } from "keycloakify/tools/useInsertLinkTags";
import type { KcContext } from "keycloakify/account/KcContext";
export type KcContextLike = {
url: {
resourcesCommonPath: string;
resourcesPath: string;
};
};
assert<keyof KcContextLike extends keyof KcContext ? true : false>();
assert<KcContext extends KcContextLike ? true : false>();
export function useInitialize(params: {
kcContext: KcContextLike;
doUseDefaultCss: boolean;
}) {
const { kcContext, doUseDefaultCss } = params;
const { url } = kcContext;
const { areAllStyleSheetsLoaded } = useInsertLinkTags({
componentOrHookName: "Template",
hrefs: !doUseDefaultCss
? []
: [
`${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`
]
});
return { isReadyToRender: areAllStyleSheetsLoaded };
}

View File

@ -1,14 +1,14 @@
import type { ReactNode } from "react";
import type { ClassKey } from "keycloakify/account/lib/kcClsx";
import type { KcContext } from "./kcContext";
import type { I18n } from "./i18n";
export type TemplateProps<KcContext, I18n> = {
export type TemplateProps<KcContext extends KcContext.Common, I18nExtended extends I18n> = {
kcContext: KcContext;
i18n: I18n;
i18n: I18nExtended;
doUseDefaultCss: boolean;
active: string;
classes?: Partial<Record<ClassKey, string>>;
children: ReactNode;
active: string;
};
export type { ClassKey };
export type ClassKey = "kcHtmlClass" | "kcBodyClass" | "kcButtonClass" | "kcButtonPrimaryClass" | "kcButtonLargeClass" | "kcButtonDefaultClass";

233
src/account/i18n/i18n.tsx Normal file
View File

@ -0,0 +1,233 @@
import "minimal-polyfills/Object.fromEntries";
//NOTE for later: https://github.com/remarkjs/react-markdown/blob/236182ecf30bd89c1e5a7652acaf8d0bf81e6170/src/renderers.js#L7-L35
import { useEffect, useState, useRef } from "react";
import fallbackMessages from "./baseMessages/en";
import { getMessages } from "./baseMessages";
import { assert } from "tsafe/assert";
import type { KcContext } from "../kcContext/KcContext";
import { Markdown } from "keycloakify/tools/Markdown";
export const fallbackLanguageTag = "en";
export type KcContextLike = {
locale?: {
currentLanguageTag: string;
supported: { languageTag: string; url: string; label: string }[];
};
};
assert<KcContext extends KcContextLike ? true : false>();
export type MessageKey = keyof typeof fallbackMessages | keyof (typeof keycloakifyExtraMessages)[typeof fallbackLanguageTag];
export type GenericI18n<MessageKey extends string> = {
/**
* e.g: "en", "fr", "zh-CN"
*
* The current language
*/
currentLanguageTag: string;
/**
* To call when the user switch language.
* This will cause the page to be reloaded,
* on next load currentLanguageTag === newLanguageTag
*/
changeLocale: (newLanguageTag: string) => never;
/**
* e.g. "en" => "English", "fr" => "Français", ...
*
* Used to render a select that enable user to switch language.
* ex: https://user-images.githubusercontent.com/6702424/186044799-38801eec-4e89-483b-81dd-8e9233e8c0eb.png
* */
labelBySupportedLanguageTag: Record<string, string>;
/**
* Examples assuming currentLanguageTag === "en"
*
* msg("access-denied") === <span>Access denied</span>
* msg("impersonateTitleHtml", "Foo") === <span><strong>Foo</strong> Impersonate User</span>
*/
msg: (key: MessageKey, ...args: (string | undefined)[]) => JSX.Element;
/**
* It's the same thing as msg() but instead of returning a JSX.Element it returns a string.
* It can be more convenient to manipulate strings but if there are HTML tags it wont render.
* msgStr("impersonateTitleHtml", "Foo") === "<strong>Foo</strong> Impersonate User"
*/
msgStr: (key: MessageKey, ...args: (string | undefined)[]) => string;
/**
* Examples assuming currentLanguageTag === "en"
* advancedMsg("${access-denied} foo bar") === <span>${msgStr("access-denied")} foo bar<span> === <span>Access denied foo bar</span>
* advancedMsg("${access-denied}") === advancedMsg("access-denied") === msg("access-denied") === <span>Access denied</span>
* advancedMsg("${not-a-message-key}") === advancedMsg(not-a-message-key") === <span>not-a-message-key</span>
*/
advancedMsg: (key: string, ...args: (string | undefined)[]) => JSX.Element;
/**
* Examples assuming currentLanguageTag === "en"
* advancedMsg("${access-denied} foo bar") === msg("access-denied") + " foo bar" === "Access denied foo bar"
* advancedMsg("${not-a-message-key}") === advancedMsg(not-a-message-key") === "not-a-message-key"
*/
advancedMsgStr: (key: string, ...args: (string | undefined)[]) => string;
};
export type I18n = GenericI18n<MessageKey>;
export function createUseI18n<ExtraMessageKey extends string = never>(extraMessages: {
[languageTag: string]: { [key in ExtraMessageKey]: string };
}) {
function useI18n(params: { kcContext: KcContextLike }): GenericI18n<MessageKey | ExtraMessageKey> | null {
const { kcContext } = params;
const [i18n, setI18n] = useState<GenericI18n<ExtraMessageKey | MessageKey> | undefined>(undefined);
const refHasStartedFetching = useRef(false);
useEffect(() => {
if (refHasStartedFetching.current) {
return;
}
refHasStartedFetching.current = true;
(async () => {
const { currentLanguageTag = fallbackLanguageTag } = kcContext.locale ?? {};
setI18n({
...createI18nTranslationFunctions({
"fallbackMessages": {
...fallbackMessages,
...(keycloakifyExtraMessages[fallbackLanguageTag] ?? {}),
...(extraMessages[fallbackLanguageTag] ?? {})
} as any,
"messages": {
...(await getMessages(currentLanguageTag)),
...((keycloakifyExtraMessages as any)[currentLanguageTag] ?? {}),
...(extraMessages[currentLanguageTag] ?? {})
} as any
}),
currentLanguageTag,
"changeLocale": newLanguageTag => {
const { locale } = kcContext;
assert(locale !== undefined, "Internationalization not enabled");
const targetSupportedLocale = locale.supported.find(({ languageTag }) => languageTag === newLanguageTag);
assert(targetSupportedLocale !== undefined, `${newLanguageTag} need to be enabled in Keycloak admin`);
window.location.href = targetSupportedLocale.url;
assert(false, "never");
},
"labelBySupportedLanguageTag": Object.fromEntries(
(kcContext.locale?.supported ?? []).map(({ languageTag, label }) => [languageTag, label])
)
});
})();
}, []);
return i18n ?? null;
}
return { useI18n };
}
function createI18nTranslationFunctions<MessageKey extends string>(params: {
fallbackMessages: Record<MessageKey, string>;
messages: Record<MessageKey, string>;
}): Pick<GenericI18n<MessageKey>, "msg" | "msgStr" | "advancedMsg" | "advancedMsgStr"> {
const { fallbackMessages, messages } = params;
function resolveMsg(props: { key: string; args: (string | undefined)[]; doRenderMarkdown: boolean }): string | JSX.Element | undefined {
const { key, args, doRenderMarkdown } = props;
const messageOrUndefined: string | undefined = (messages as any)[key] ?? (fallbackMessages as any)[key];
if (messageOrUndefined === undefined) {
return undefined;
}
const message = messageOrUndefined;
const messageWithArgsInjectedIfAny = (() => {
const startIndex = message
.match(/{[0-9]+}/g)
?.map(g => g.match(/{([0-9]+)}/)![1])
.map(indexStr => parseInt(indexStr))
.sort((a, b) => a - b)[0];
if (startIndex === undefined) {
// No {0} in message (no arguments expected)
return message;
}
let messageWithArgsInjected = message;
args.forEach((arg, i) => {
if (arg === undefined) {
return;
}
messageWithArgsInjected = messageWithArgsInjected.replace(new RegExp(`\\{${i + startIndex}\\}`, "g"), arg);
});
return messageWithArgsInjected;
})();
return doRenderMarkdown ? (
<Markdown allowDangerousHtml renderers={{ "paragraph": "span" }}>
{messageWithArgsInjectedIfAny}
</Markdown>
) : (
messageWithArgsInjectedIfAny
);
}
function resolveMsgAdvanced(props: { key: string; args: (string | undefined)[]; doRenderMarkdown: boolean }): JSX.Element | string {
const { key, args, doRenderMarkdown } = props;
const match = key.match(/^\$\{([^{]+)\}$/);
const keyUnwrappedFromCurlyBraces = match === null ? key : match[1];
const out = resolveMsg({
"key": keyUnwrappedFromCurlyBraces,
args,
doRenderMarkdown
});
return (out !== undefined ? out : doRenderMarkdown ? <span>{keyUnwrappedFromCurlyBraces}</span> : keyUnwrappedFromCurlyBraces) as any;
}
return {
"msgStr": (key, ...args) => resolveMsg({ key, args, "doRenderMarkdown": false }) as string,
"msg": (key, ...args) => resolveMsg({ key, args, "doRenderMarkdown": true }) as JSX.Element,
"advancedMsg": (key, ...args) => resolveMsgAdvanced({ key, args, "doRenderMarkdown": true }) as JSX.Element,
"advancedMsgStr": (key, ...args) => resolveMsgAdvanced({ key, args, "doRenderMarkdown": false }) as string
};
}
const keycloakifyExtraMessages = {
"en": {
"shouldBeEqual": "{0} should be equal to {1}",
"shouldBeDifferent": "{0} should be different to {1}",
"shouldMatchPattern": "Pattern should match: `/{0}/`",
"mustBeAnInteger": "Must be an integer",
"notAValidOption": "Not a valid option",
"newPasswordSameAsOld": "New password must be different from the old one",
"passwordConfirmNotMatch": "Password confirmation does not match"
},
"fr": {
/* spell-checker: disable */
"shouldBeEqual": "{0} doit être égal à {1}",
"shouldBeDifferent": "{0} doit être différent de {1}",
"shouldMatchPattern": "Dois respecter le schéma: `/{0}/`",
"mustBeAnInteger": "Doit être un nombre entier",
"notAValidOption": "N'est pas une option valide",
"logoutConfirmTitle": "Déconnexion",
"logoutConfirmHeader": "Êtes-vous sûr(e) de vouloir vous déconnecter ?",
"doLogout": "Se déconnecter",
"newPasswordSameAsOld": "Le nouveau mot de passe doit être différent de l'ancien",
"passwordConfirmNotMatch": "La confirmation du mot de passe ne correspond pas"
/* spell-checker: enable */
}
};

View File

@ -0,0 +1 @@
export type { I18n } from "./i18n";

View File

@ -1,3 +1,10 @@
export type { ExtendKcContext } from "keycloakify/account/KcContext";
export type { ClassKey } from "keycloakify/account/TemplateProps";
export { i18nBuilder, type MessageKey_defaultSet } from "keycloakify/account/i18n";
import Fallback from "keycloakify/account/Fallback";
export default Fallback;
export { getKcContext } from "keycloakify/account/kcContext/getKcContext";
export { createGetKcContext } from "keycloakify/account/kcContext/createGetKcContext";
export type { AccountThemePageId as PageId } from "keycloakify/bin/keycloakify/generateFtl";
export { createUseI18n } from "keycloakify/account/i18n/i18n";
export type { PageProps } from "keycloakify/account/pages/PageProps";

View File

@ -0,0 +1,126 @@
import type { AccountThemePageId, ThemeType } from "keycloakify/bin/keycloakify/generateFtl";
import { assert } from "tsafe/assert";
import type { Equals } from "tsafe";
export type KcContext = KcContext.Password | KcContext.Account;
export declare namespace KcContext {
export type Common = {
themeVersion: string;
keycloakifyVersion: string;
themeType: "account";
themeName: string;
locale?: {
supported: {
url: string;
label: string;
languageTag: string;
}[];
currentLanguageTag: string;
};
url: {
accountUrl: string;
passwordUrl: string;
totpUrl: string;
socialUrl: string;
sessionsUrl: string;
applicationsUrl: string;
logUrl: string;
resourceUrl: string;
resourcesCommonPath: string;
resourcesPath: string;
/** @deprecated, not present in recent keycloak version apparently, use kcContext.referrer instead */
referrerURI?: string;
getLogoutUrl: () => string;
};
features: {
passwordUpdateSupported: boolean;
identityFederation: boolean;
log: boolean;
authorization: boolean;
};
realm: {
internationalizationEnabled: boolean;
userManagedAccessAllowed: boolean;
};
// Present only if redirected to account page with ?referrer=xxx&referrer_uri=http...
message?: {
type: "success" | "warning" | "error" | "info";
summary: string;
};
referrer?: {
url: string; // The url of the App
name: string; // Client id
};
messagesPerField: {
/**
* Return text if message for given field exists. Useful eg. to add css styles for fields with message.
*
* @param fieldName to check for
* @param text to return
* @return text if message exists for given field, else undefined
*/
printIfExists: <T extends string>(fieldName: string, text: T) => T | undefined;
/**
* Check if exists error message for given fields
*
* @param fields
* @return boolean
*/
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: {
email?: string;
firstName: string;
lastName?: string;
username?: string;
};
};
export type Password = Common & {
pageId: "password.ftl";
password: {
passwordSet: boolean;
};
stateChecker: string;
};
export type Account = Common & {
pageId: "account.ftl";
url: {
accountUrl: string;
};
realm: {
registrationEmailAsUsername: boolean;
editUsernameAllowed: boolean;
};
stateChecker: string;
};
}
{
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>();

View File

@ -0,0 +1,102 @@
import type { DeepPartial } from "keycloakify/tools/DeepPartial";
import { deepAssign } from "keycloakify/tools/deepAssign";
import type { ExtendKcContext } from "./getKcContextFromWindow";
import { getKcContextFromWindow } from "./getKcContextFromWindow";
import { pathJoin } from "keycloakify/bin/tools/pathJoin";
import { pathBasename } from "keycloakify/tools/pathBasename";
import { resourcesCommonDirPathRelativeToPublicDir } from "keycloakify/bin/mockTestingResourcesPath";
import { symToStr } from "tsafe/symToStr";
import { kcContextMocks, kcContextCommonMock } from "keycloakify/account/kcContext/kcContextMocks";
export function createGetKcContext<KcContextExtension extends { pageId: string } = never>(params?: {
mockData?: readonly DeepPartial<ExtendKcContext<KcContextExtension>>[];
}) {
const { mockData } = params ?? {};
function getKcContext<PageId extends ExtendKcContext<KcContextExtension>["pageId"] | undefined = undefined>(params?: {
mockPageId?: PageId;
storyPartialKcContext?: DeepPartial<Extract<ExtendKcContext<KcContextExtension>, { pageId: PageId }>>;
}): {
kcContext: PageId extends undefined
? ExtendKcContext<KcContextExtension> | undefined
: Extract<ExtendKcContext<KcContextExtension>, { pageId: PageId }>;
} {
const { mockPageId, storyPartialKcContext } = params ?? {};
const realKcContext = getKcContextFromWindow<KcContextExtension>();
if (mockPageId !== undefined && realKcContext === undefined) {
//TODO maybe trow if no mock fo custom page
console.log(`%cKeycloakify: ${symToStr({ mockPageId })} set to ${mockPageId}.`, "background: red; color: yellow; font-size: medium");
const kcContextDefaultMock = kcContextMocks.find(({ pageId }) => pageId === mockPageId);
const partialKcContextCustomMock = (() => {
const out: DeepPartial<ExtendKcContext<KcContextExtension>> = {};
const mockDataPick = mockData?.find(({ pageId }) => pageId === mockPageId);
if (mockDataPick !== undefined) {
deepAssign({
"target": out,
"source": mockDataPick
});
}
if (storyPartialKcContext !== undefined) {
deepAssign({
"target": out,
"source": storyPartialKcContext
});
}
return Object.keys(out).length === 0 ? undefined : out;
})();
if (kcContextDefaultMock === undefined && partialKcContextCustomMock === undefined) {
console.warn(
[
`WARNING: You declared the non build in page ${mockPageId} but you didn't `,
`provide mock data needed to debug the page outside of Keycloak as you are trying to do now.`,
`Please check the documentation of the getKcContext function`
].join("\n")
);
}
const kcContext: any = {};
deepAssign({
"target": kcContext,
"source": kcContextDefaultMock !== undefined ? kcContextDefaultMock : { "pageId": mockPageId, ...kcContextCommonMock }
});
if (partialKcContextCustomMock !== undefined) {
deepAssign({
"target": kcContext,
"source": partialKcContextCustomMock
});
}
return { kcContext };
}
if (realKcContext === undefined) {
return { "kcContext": undefined as any };
}
if (realKcContext.themeType !== "account") {
return { "kcContext": undefined as any };
}
{
const { url } = realKcContext;
url.resourcesCommonPath = pathJoin(url.resourcesPath, pathBasename(resourcesCommonDirPathRelativeToPublicDir));
}
return { "kcContext": realKcContext as any };
}
return { getKcContext };
}

View File

@ -0,0 +1,21 @@
import type { DeepPartial } from "keycloakify/tools/DeepPartial";
import type { ExtendKcContext } from "./getKcContextFromWindow";
import { createGetKcContext } from "./createGetKcContext";
/** 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>>[];
}): { kcContext: ExtendKcContext<KcContextExtension> | undefined } {
const { mockPageId, mockData } = params ?? {};
const { getKcContext } = createGetKcContext({
mockData
});
const { kcContext } = getKcContext({ mockPageId });
return { kcContext };
}

View File

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

View File

@ -0,0 +1 @@
export type { KcContext } from "./KcContext";

View File

@ -0,0 +1,175 @@
import "minimal-polyfills/Object.fromEntries";
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 = (typeof process !== "object" ? undefined : process.env?.["PUBLIC_URL"]) || "/";
export const kcContextCommonMock: KcContext.Common = {
"themeVersion": "0.0.0",
"keycloakifyVersion": "0.0.0",
"themeType": "account",
"themeName": "my-theme-name",
"url": {
"resourcesPath": pathJoin(PUBLIC_URL, resourcesDirPathRelativeToPublicDir),
"resourcesCommonPath": pathJoin(PUBLIC_URL, resourcesCommonDirPathRelativeToPublicDir),
"resourceUrl": "#",
"accountUrl": "#",
"applicationsUrl": "#",
"getLogoutUrl": () => "#",
"logUrl": "#",
"passwordUrl": "#",
"sessionsUrl": "#",
"socialUrl": "#",
"totpUrl": "#"
},
"realm": {
"internationalizationEnabled": true,
"userManagedAccessAllowed": true
},
"messagesPerField": {
"printIfExists": () => {
return undefined;
},
"existsError": () => false,
"get": key => `Fake error for ${key}`,
"exists": () => false
},
"locale": {
"supported": [
/* spell-checker: disable */
{
"url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=de",
"label": "Deutsch",
"languageTag": "de"
},
{
"url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=no",
"label": "Norsk",
"languageTag": "no"
},
{
"url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=ru",
"label": "Русский",
"languageTag": "ru"
},
{
"url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=sv",
"label": "Svenska",
"languageTag": "sv"
},
{
"url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=pt-BR",
"label": "Português (Brasil)",
"languageTag": "pt-BR"
},
{
"url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=lt",
"label": "Lietuvių",
"languageTag": "lt"
},
{
"url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=en",
"label": "English",
"languageTag": "en"
},
{
"url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=it",
"label": "Italiano",
"languageTag": "it"
},
{
"url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=fr",
"label": "Français",
"languageTag": "fr"
},
{
"url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=zh-CN",
"label": "中文简体",
"languageTag": "zh-CN"
},
{
"url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=es",
"label": "Español",
"languageTag": "es"
},
{
"url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=cs",
"label": "Čeština",
"languageTag": "cs"
},
{
"url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=ja",
"label": "日本語",
"languageTag": "ja"
},
{
"url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=sk",
"label": "Slovenčina",
"languageTag": "sk"
},
{
"url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=pl",
"label": "Polski",
"languageTag": "pl"
},
{
"url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=ca",
"label": "Català",
"languageTag": "ca"
},
{
"url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=nl",
"label": "Nederlands",
"languageTag": "nl"
},
{
"url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=tr",
"label": "Türkçe",
"languageTag": "tr"
}
/* spell-checker: enable */
],
"currentLanguageTag": "en"
},
"features": {
"authorization": true,
"identityFederation": true,
"log": true,
"passwordUpdateSupported": true
},
"referrer": undefined,
"account": {
"firstName": "john",
"lastName": "doe",
"email": "john.doe@code.gouv.fr",
"username": "doe_j"
}
};
export const kcContextMocks: KcContext[] = [
id<KcContext.Password>({
...kcContextCommonMock,
"pageId": "password.ftl",
"password": {
"passwordSet": true
},
"stateChecker": "state checker"
}),
id<KcContext.Account>({
...kcContextCommonMock,
"pageId": "account.ftl",
"url": {
...kcContextCommonMock.url,
"referrerURI": "#",
"accountUrl": "#"
},
"realm": {
...kcContextCommonMock.realm,
"registrationEmailAsUsername": true,
"editUsernameAllowed": true
},
"stateChecker": ""
})
];

View File

@ -1,37 +0,0 @@
import { createGetKcClsx } from "keycloakify/lib/getKcClsx";
export type ClassKey =
| "kcHtmlClass"
| "kcBodyClass"
| "kcButtonClass"
| "kcButtonPrimaryClass"
| "kcButtonLargeClass"
| "kcButtonDefaultClass"
| "kcContentWrapperClass"
| "kcFormClass"
| "kcFormGroupClass"
| "kcInputWrapperClass"
| "kcLabelClass"
| "kcInputClass"
| "kcInputErrorMessageClass";
export const { getKcClsx } = createGetKcClsx<ClassKey>({
defaultClasses: {
kcHtmlClass: undefined,
kcBodyClass: undefined,
kcButtonClass: "btn",
kcContentWrapperClass: "row",
kcButtonPrimaryClass: "btn-primary",
kcButtonLargeClass: "btn-lg",
kcButtonDefaultClass: "btn-default",
kcFormClass: "form-horizontal",
kcFormGroupClass: "form-group",
kcInputWrapperClass: "col-xs-12 col-sm-12 col-md-12 col-lg-12",
kcLabelClass: "control-label",
kcInputClass: "form-control",
kcInputErrorMessageClass:
"pf-c-form__helper-text pf-m-error required kc-feedback-text"
}
});
export type KcClsx = ReturnType<typeof getKcClsx>["kcClsx"];

View File

@ -0,0 +1,13 @@
import { createUseClassName } from "keycloakify/lib/useGetClassName";
import type { ClassKey } from "keycloakify/account/TemplateProps";
export const { useGetClassName } = createUseClassName<ClassKey>({
"defaultClasses": {
"kcHtmlClass": undefined,
"kcBodyClass": undefined,
"kcButtonClass": "btn",
"kcButtonPrimaryClass": "btn-primary",
"kcButtonLargeClass": "btn-lg",
"kcButtonDefaultClass": "btn-default"
}
});

View File

@ -1,20 +1,18 @@
import { clsx } from "keycloakify/tools/clsx";
import type { PageProps } from "keycloakify/account/pages/PageProps";
import { getKcClsx } from "keycloakify/account/lib/kcClsx";
import type { KcContext } from "../KcContext";
import { useGetClassName } from "keycloakify/account/lib/useGetClassName";
import type { KcContext } from "../kcContext";
import type { I18n } from "../i18n";
export default function Account(props: PageProps<Extract<KcContext, { pageId: "account.ftl" }>, I18n>) {
const { kcContext, i18n, doUseDefaultCss, Template } = props;
const { kcContext, i18n, doUseDefaultCss, Template, classes } = props;
const classes = {
...props.classes,
kcBodyClass: clsx(props.classes?.kcBodyClass, "user")
};
const { kcClsx } = getKcClsx({
const { getClassName } = useGetClassName({
doUseDefaultCss,
classes
"classes": {
...classes,
"kcBodyClass": clsx(classes?.kcBodyClass, "user")
}
});
const { url, realm, messagesPerField, stateChecker, account, referrer } = kcContext;
@ -104,7 +102,11 @@ export default function Account(props: PageProps<Extract<KcContext, { pageId: "a
{referrer !== undefined && <a href={referrer?.url}>{msg("backToApplication")}</a>}
<button
type="submit"
className={kcClsx("kcButtonClass", "kcButtonPrimaryClass", "kcButtonLargeClass")}
className={clsx(
getClassName("kcButtonClass"),
getClassName("kcButtonPrimaryClass"),
getClassName("kcButtonLargeClass")
)}
name="submitAction"
value="Save"
>
@ -112,7 +114,11 @@ export default function Account(props: PageProps<Extract<KcContext, { pageId: "a
</button>
<button
type="submit"
className={kcClsx("kcButtonClass", "kcButtonDefaultClass", "kcButtonLargeClass")}
className={clsx(
getClassName("kcButtonClass"),
getClassName("kcButtonDefaultClass"),
getClassName("kcButtonLargeClass")
)}
name="submitAction"
value="Cancel"
>

View File

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

View File

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

View File

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

View File

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

View File

@ -1,21 +1,19 @@
import { useState } from "react";
import { clsx } from "keycloakify/tools/clsx";
import { getKcClsx } from "keycloakify/account/lib/kcClsx";
import type { PageProps } from "keycloakify/account/pages/PageProps";
import type { KcContext } from "../KcContext";
import { useGetClassName } from "keycloakify/account/lib/useGetClassName";
import type { KcContext } from "../kcContext";
import type { I18n } from "../i18n";
export default function Password(props: PageProps<Extract<KcContext, { pageId: "password.ftl" }>, I18n>) {
const { kcContext, i18n, doUseDefaultCss, Template } = props;
const { kcContext, i18n, doUseDefaultCss, Template, classes } = props;
const classes = {
...props.classes,
kcBodyClass: clsx(props.classes?.kcBodyClass, "password")
};
const { kcClsx } = getKcClsx({
const { getClassName } = useGetClassName({
doUseDefaultCss,
classes
"classes": {
...classes,
"kcBodyClass": clsx(classes?.kcBodyClass, "password")
}
});
const { url, password, account, stateChecker } = kcContext;
@ -59,18 +57,18 @@ export default function Password(props: PageProps<Extract<KcContext, { pageId: "
{...{
kcContext: {
...kcContext,
message: (() => {
"message": (() => {
if (newPasswordError !== "") {
return {
type: "error",
summary: newPasswordError
"type": "error",
"summary": newPasswordError
};
}
if (newPasswordConfirmError !== "") {
return {
type: "error",
summary: newPasswordConfirmError
"type": "error",
"summary": newPasswordConfirmError
};
}
@ -100,7 +98,7 @@ export default function Password(props: PageProps<Extract<KcContext, { pageId: "
value={account.username ?? ""}
autoComplete="username"
readOnly
style={{ display: "none" }}
style={{ "display": "none" }}
/>
{password.passwordSet && (
@ -194,7 +192,11 @@ export default function Password(props: PageProps<Extract<KcContext, { pageId: "
<button
disabled={newPasswordError !== "" || newPasswordConfirmError !== ""}
type="submit"
className={kcClsx("kcButtonClass", "kcButtonPrimaryClass", "kcButtonLargeClass")}
className={clsx(
getClassName("kcButtonClass"),
getClassName("kcButtonPrimaryClass"),
getClassName("kcButtonLargeClass")
)}
name="submitAction"
value="Save"
>

View File

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

View File

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

View File

@ -1,165 +0,0 @@
import { getThisCodebaseRootDirPath } from "./tools/getThisCodebaseRootDirPath";
import cliSelect from "cli-select";
import {
LOGIN_THEME_PAGE_IDS,
ACCOUNT_THEME_PAGE_IDS,
type LoginThemePageId,
type AccountThemePageId,
THEME_TYPES
} from "./shared/constants";
import { capitalize } from "tsafe/capitalize";
import * as fs from "fs";
import { join as pathJoin, relative as pathRelative, dirname as pathDirname } from "path";
import { kebabCaseToCamelCase } from "./tools/kebabCaseToSnakeCase";
import { assert, Equals } from "tsafe/assert";
import type { BuildContext } from "./shared/buildContext";
import chalk from "chalk";
import { runPrettier, getIsPrettierAvailable } from "./tools/runPrettier";
import { maybeDelegateCommandToCustomHandler } from "./shared/customHandler_delegate";
export async function command(params: { buildContext: BuildContext }) {
const { buildContext } = params;
const { hasBeenHandled } = await maybeDelegateCommandToCustomHandler({
commandName: "add-story",
buildContext
});
if (hasBeenHandled) {
return;
}
console.log(chalk.cyan("Theme type:"));
const themeType = await (async () => {
const values = THEME_TYPES.filter(themeType => {
switch (themeType) {
case "account":
return buildContext.implementedThemeTypes.account.isImplemented;
case "login":
return buildContext.implementedThemeTypes.login.isImplemented;
case "admin":
return buildContext.implementedThemeTypes.admin.isImplemented;
}
assert<Equals<typeof themeType, never>>(false);
});
assert(values.length > 0, "No theme is implemented in this project");
if (values.length === 1) {
return values[0];
}
const { value } = await cliSelect({
values
}).catch(() => {
process.exit(-1);
});
return value;
})();
if (
themeType === "account" &&
(assert(buildContext.implementedThemeTypes.account.isImplemented),
buildContext.implementedThemeTypes.account.type === "Single-Page")
) {
console.log(
`${chalk.red("✗")} Sorry, there is no Storybook support for Single-Page Account themes.`
);
process.exit(0);
return;
}
if (themeType === "admin") {
console.log(
`${chalk.red("✗")} Sorry, there is no Storybook support for the Admin UI.`
);
process.exit(0);
return;
}
console.log(`${themeType}`);
console.log(chalk.cyan("Select the page you want to create a Storybook for:"));
const { value: pageId } = await cliSelect<LoginThemePageId | AccountThemePageId>({
values: (() => {
switch (themeType) {
case "login":
return [...LOGIN_THEME_PAGE_IDS];
case "account":
return [...ACCOUNT_THEME_PAGE_IDS];
}
assert<Equals<typeof themeType, never>>(false);
})()
}).catch(() => {
process.exit(-1);
});
console.log(`${pageId}`);
const componentBasename = capitalize(kebabCaseToCamelCase(pageId)).replace(
/ftl$/,
"stories.tsx"
);
const targetFilePath = pathJoin(
buildContext.themeSrcDirPath,
themeType,
"pages",
componentBasename
);
if (fs.existsSync(targetFilePath)) {
console.log(`${pathRelative(process.cwd(), targetFilePath)} already exists`);
process.exit(-1);
}
let sourceCode = fs
.readFileSync(
pathJoin(
getThisCodebaseRootDirPath(),
"stories",
themeType,
"pages",
componentBasename
)
)
.toString("utf8")
.replace('import React from "react";\n', "")
.replace(/from "[./]+dist\//, 'from "keycloakify/');
run_prettier: {
if (!(await getIsPrettierAvailable())) {
break run_prettier;
}
sourceCode = await runPrettier({
filePath: targetFilePath,
sourceCode: sourceCode
});
}
{
const targetDirPath = pathDirname(targetFilePath);
if (!fs.existsSync(targetDirPath)) {
fs.mkdirSync(targetDirPath, { recursive: true });
}
}
fs.writeFileSync(targetFilePath, Buffer.from(sourceCode, "utf8"));
console.log(
[
`${chalk.green("✓")} ${chalk.bold(
pathJoin(".", pathRelative(process.cwd(), targetFilePath))
)} copy pasted from the Keycloakify source code into your project`,
`You can start storybook with ${chalk.bold("npm run storybook")}`
].join("\n")
);
}

View File

@ -1,96 +1,49 @@
import { maybeDelegateCommandToCustomHandler } from "./shared/customHandler_delegate";
import { join as pathJoin, dirname as pathDirname } from "path";
import { WELL_KNOWN_DIRECTORY_BASE_NAME } from "./shared/constants";
import { readThisNpmPackageVersion } from "./tools/readThisNpmPackageVersion";
#!/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";
import { rmSync } from "./tools/fs.rmSync";
import type { BuildContext } from "./shared/buildContext";
import { transformCodebase } from "./tools/transformCodebase";
import { getThisCodebaseRootDirPath } from "./tools/getThisCodebaseRootDirPath";
export async function command(params: { buildContext: BuildContext }) {
const { buildContext } = params;
(async () => {
const projectDirPath = process.cwd();
const { hasBeenHandled } = await maybeDelegateCommandToCustomHandler({
commandName: "copy-keycloak-resources-to-public",
buildContext
const buildOptions = readBuildOptions({
"processArgv": process.argv.slice(2),
"projectDirPath": process.cwd()
});
if (hasBeenHandled) {
const keycloakDirInPublicDir = pathJoin(process.env["PUBLIC_DIR_PATH"] || pathJoin(projectDirPath, "public"), basenameOfKeycloakDirInPublicDir);
if (fs.existsSync(keycloakDirInPublicDir)) {
console.log(`${pathRelative(projectDirPath, keycloakDirInPublicDir)} already exists.`);
return;
}
const destDirPath = pathJoin(
buildContext.publicDirPath,
WELL_KNOWN_DIRECTORY_BASE_NAME.KEYCLOAKIFY_DEV_RESOURCES
);
const keycloakifyBuildinfoFilePath = pathJoin(destDirPath, "keycloakify.buildinfo");
const keycloakifyBuildinfoRaw = JSON.stringify(
{
keycloakifyVersion: readThisNpmPackageVersion()
},
null,
2
);
skip_if_already_done: {
if (!fs.existsSync(keycloakifyBuildinfoFilePath)) {
break skip_if_already_done;
}
const keycloakifyBuildinfoRaw_previousRun = fs
.readFileSync(keycloakifyBuildinfoFilePath)
.toString("utf8");
if (keycloakifyBuildinfoRaw_previousRun !== keycloakifyBuildinfoRaw) {
break skip_if_already_done;
}
return;
for (const themeType of themeTypes) {
await downloadKeycloakStaticResources({
projectDirPath,
"keycloakVersion": buildOptions.keycloakVersionDefaultAssets,
"themeType": themeType,
"themeDirPath": keycloakDirInPublicDir,
"usedResources": undefined
});
}
rmSync(destDirPath, { force: true, recursive: true });
// NOTE: To remove in a while, remove the legacy keycloak-resources directory
rmSync(pathJoin(pathDirname(destDirPath), "keycloak-resources"), {
force: true,
recursive: true
});
rmSync(pathJoin(pathDirname(destDirPath), ".keycloakify"), {
force: true,
recursive: true
});
fs.mkdirSync(destDirPath, { recursive: true });
fs.writeFileSync(pathJoin(destDirPath, ".gitignore"), Buffer.from("*", "utf8"));
transformCodebase({
srcDirPath: pathJoin(
getThisCodebaseRootDirPath(),
"res",
"public",
WELL_KNOWN_DIRECTORY_BASE_NAME.KEYCLOAKIFY_DEV_RESOURCES
),
destDirPath
});
fs.writeFileSync(
pathJoin(destDirPath, "README.txt"),
pathJoin(keycloakDirInPublicDir, "README.txt"),
Buffer.from(
// prettier-ignore
[
"This directory is only used in dev mode by Keycloakify",
"It won't be included in your final build.",
"Do not modify anything in this directory.",
].join("\n")
"This is just a test folder that helps develop",
"the login and register page without having to run a Keycloak container"
].join(" ")
)
);
fs.writeFileSync(
keycloakifyBuildinfoFilePath,
Buffer.from(keycloakifyBuildinfoRaw, "utf8")
);
}
fs.writeFileSync(pathJoin(keycloakDirInPublicDir, ".gitignore"), Buffer.from("*", "utf8"));
console.log(`${pathRelative(projectDirPath, keycloakDirInPublicDir)} directory created.`);
})();

View File

@ -0,0 +1,97 @@
#!/usr/bin/env node
import { join as pathJoin } from "path";
import { downloadAndUnzip } from "./tools/downloadAndUnzip";
import { promptKeycloakVersion } from "./promptKeycloakVersion";
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: { projectDirPath: string; keycloakVersion: string; destDirPath: string }) {
const { projectDirPath, keycloakVersion, destDirPath } = params;
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 buildOptions = readBuildOptions({
"projectDirPath": process.cwd(),
"processArgv": process.argv.slice(2)
});
const logger = getLogger({ "isSilent": buildOptions.isSilent });
const { keycloakVersion } = await promptKeycloakVersion();
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
});
}
if (require.main === module) {
main();
}

View File

@ -0,0 +1,65 @@
#!/usr/bin/env node
import { getProjectRoot } from "./tools/getProjectRoot";
import cliSelect from "cli-select";
import {
loginThemePageIds,
accountThemePageIds,
type LoginThemePageId,
type AccountThemePageId,
themeTypes,
type ThemeType
} from "./keycloakify/generateFtl";
import { capitalize } from "tsafe/capitalize";
import { readFile, writeFile } from "fs/promises";
import { existsSync } from "fs";
import { join as pathJoin, relative as pathRelative } from "path";
import { kebabCaseToCamelCase } from "./tools/kebabCaseToSnakeCase";
import { assert, Equals } from "tsafe/assert";
import { getThemeSrcDirPath } from "./getSrcDirPath";
(async () => {
console.log("Select a theme type");
const { value: themeType } = await cliSelect<ThemeType>({
"values": [...themeTypes]
}).catch(() => {
console.log("Aborting");
process.exit(-1);
});
console.log("Select a page you would like to eject");
const { value: pageId } = await cliSelect<LoginThemePageId | AccountThemePageId>({
"values": (() => {
switch (themeType) {
case "login":
return [...loginThemePageIds];
case "account":
return [...accountThemePageIds];
}
assert<Equals<typeof themeType, never>>(false);
})()
}).catch(() => {
console.log("Aborting");
process.exit(-1);
});
const pageBasename = capitalize(kebabCaseToCamelCase(pageId)).replace(/ftl$/, "tsx");
const { themeSrcDirPath } = getThemeSrcDirPath({ "projectDirPath": process.cwd() });
const targetFilePath = pathJoin(themeSrcDirPath, themeType, "pages", pageBasename);
if (existsSync(targetFilePath)) {
console.log(`${pageId} is already ejected, ${pathRelative(process.cwd(), targetFilePath)} already exists`);
process.exit(-1);
}
await writeFile(targetFilePath, await readFile(pathJoin(getProjectRoot(), "src", themeType, "pages", pageBasename)));
console.log(`${pathRelative(process.cwd(), targetFilePath)} created`);
})();

View File

@ -1,332 +0,0 @@
#!/usr/bin/env node
import { getThisCodebaseRootDirPath } from "./tools/getThisCodebaseRootDirPath";
import cliSelect from "cli-select";
import {
LOGIN_THEME_PAGE_IDS,
ACCOUNT_THEME_PAGE_IDS,
type LoginThemePageId,
type AccountThemePageId,
THEME_TYPES
} from "./shared/constants";
import { capitalize } from "tsafe/capitalize";
import * as fs from "fs";
import { join as pathJoin, relative as pathRelative, dirname as pathDirname } from "path";
import { kebabCaseToCamelCase } from "./tools/kebabCaseToSnakeCase";
import { assert, Equals } from "tsafe/assert";
import type { BuildContext } from "./shared/buildContext";
import chalk from "chalk";
import { maybeDelegateCommandToCustomHandler } from "./shared/customHandler_delegate";
import { runPrettier, getIsPrettierAvailable } from "./tools/runPrettier";
export async function command(params: { buildContext: BuildContext }) {
const { buildContext } = params;
const { hasBeenHandled } = await maybeDelegateCommandToCustomHandler({
commandName: "eject-page",
buildContext
});
if (hasBeenHandled) {
return;
}
console.log(chalk.cyan("Theme type:"));
const themeType = await (async () => {
const values = THEME_TYPES.filter(themeType => {
switch (themeType) {
case "account":
return buildContext.implementedThemeTypes.account.isImplemented;
case "login":
return buildContext.implementedThemeTypes.login.isImplemented;
case "admin":
return buildContext.implementedThemeTypes.admin.isImplemented;
}
assert<Equals<typeof themeType, never>>(false);
});
assert(values.length > 0, "No theme is implemented in this project");
if (values.length === 1) {
return values[0];
}
const { value } = await cliSelect({
values
}).catch(() => {
process.exit(-1);
});
return value;
})();
if (themeType === "admin") {
console.log("Use `npx keycloakify own` command instead, see documentation");
process.exit(-1);
}
if (
themeType === "account" &&
(assert(buildContext.implementedThemeTypes.account.isImplemented),
buildContext.implementedThemeTypes.account.type === "Single-Page")
) {
console.log(
chalk.yellow(
[
"You are implementing a Single-Page Account theme.",
"The eject-page command isn't applicable in this context"
].join("\n")
)
);
process.exit(1);
return;
}
console.log(`${themeType}`);
console.log(chalk.cyan("Select the page you want to customize:"));
const templateValue = "Template.tsx (Layout common to every page)";
const userProfileFormFieldsValue =
"UserProfileFormFields.tsx (Renders the form of the register.ftl, login-update-profile.ftl, update-email.ftl and idp-review-user-profile.ftl)";
const otherPageValue = "The page you're looking for isn't listed here";
const { value: pageIdOrComponent } = await cliSelect<
| LoginThemePageId
| AccountThemePageId
| typeof templateValue
| typeof userProfileFormFieldsValue
| typeof otherPageValue
>({
values: (() => {
switch (themeType) {
case "login":
return [
templateValue,
userProfileFormFieldsValue,
...LOGIN_THEME_PAGE_IDS,
otherPageValue
];
case "account":
return [templateValue, ...ACCOUNT_THEME_PAGE_IDS, otherPageValue];
}
assert<Equals<typeof themeType, never>>(false);
})()
}).catch(() => {
process.exit(-1);
});
if (pageIdOrComponent === otherPageValue) {
console.log(
[
"To style a page not included in the base Keycloak, such as one added by a third-party Keycloak extension,",
"refer to the documentation: https://docs.keycloakify.dev/features/styling-a-custom-page-not-included-in-base-keycloak"
].join(" ")
);
process.exit(0);
}
console.log(`${pageIdOrComponent}`);
const componentBasename = (() => {
if (pageIdOrComponent === templateValue) {
return "Template.tsx";
}
if (pageIdOrComponent === userProfileFormFieldsValue) {
return "UserProfileFormFields.tsx";
}
return capitalize(kebabCaseToCamelCase(pageIdOrComponent)).replace(/ftl$/, "tsx");
})();
const pagesOrDot = (() => {
if (
pageIdOrComponent === templateValue ||
pageIdOrComponent === userProfileFormFieldsValue
) {
return ".";
}
return "pages";
})();
const targetFilePath = pathJoin(
buildContext.themeSrcDirPath,
themeType,
pagesOrDot,
componentBasename
);
if (fs.existsSync(targetFilePath)) {
console.log(
`${pageIdOrComponent} is already ejected, ${pathRelative(
process.cwd(),
targetFilePath
)} already exists`
);
process.exit(-1);
}
let componentCode = fs
.readFileSync(
pathJoin(
getThisCodebaseRootDirPath(),
"src",
themeType,
pagesOrDot,
componentBasename
)
)
.toString("utf8");
run_prettier: {
if (!(await getIsPrettierAvailable())) {
break run_prettier;
}
componentCode = await runPrettier({
filePath: targetFilePath,
sourceCode: componentCode
});
}
{
const targetDirPath = pathDirname(targetFilePath);
if (!fs.existsSync(targetDirPath)) {
fs.mkdirSync(targetDirPath, { recursive: true });
}
}
fs.writeFileSync(targetFilePath, Buffer.from(componentCode, "utf8"));
console.log(
`${chalk.green("✓")} ${chalk.bold(
pathJoin(".", pathRelative(process.cwd(), targetFilePath))
)} copy pasted from the Keycloakify source code into your project`
);
edit_KcPage: {
if (
pageIdOrComponent !== templateValue &&
pageIdOrComponent !== userProfileFormFieldsValue
) {
break edit_KcPage;
}
const kcAppTsxPath = pathJoin(
buildContext.themeSrcDirPath,
themeType,
"KcPage.tsx"
);
const kcAppTsxCode = fs.readFileSync(kcAppTsxPath).toString("utf8");
const modifiedKcAppTsxCode = (() => {
switch (pageIdOrComponent) {
case templateValue:
return kcAppTsxCode.replace(
`keycloakify/${themeType}/Template`,
"./Template"
);
case userProfileFormFieldsValue:
return kcAppTsxCode.replace(
`keycloakify/login/UserProfileFormFields`,
"./UserProfileFormFields"
);
}
assert<Equals<typeof pageIdOrComponent, never>>(false);
})();
if (kcAppTsxCode === modifiedKcAppTsxCode) {
console.log(
chalk.red(
"Unable to automatically update KcPage.tsx, please update it manually"
)
);
return;
}
fs.writeFileSync(kcAppTsxPath, Buffer.from(modifiedKcAppTsxCode, "utf8"));
console.log(
`${chalk.green("✓")} ${chalk.bold(
pathJoin(".", pathRelative(process.cwd(), kcAppTsxPath))
)} Updated`
);
return;
}
const userProfileFormFieldComponentName = "UserProfileFormFields";
const componentName = componentBasename.replace(/.tsx$/, "");
console.log(
[
``,
`You now need to update your page router:`,
``,
`${chalk.bold(
pathJoin(
".",
pathRelative(process.cwd(), buildContext.themeSrcDirPath),
themeType,
"KcPage.tsx"
)
)}:`,
chalk.grey("```"),
`// ...`,
``,
chalk.green(
`+const ${componentName} = lazy(() => import("./pages/${componentName}"));`
),
...[
``,
` export default function KcPage(props: { kcContext: KcContext; }) {`,
``,
` // ...`,
``,
` return (`,
` <Suspense>`,
` {(() => {`,
` switch (kcContext.pageId) {`,
` // ...`,
`+ case "${pageIdOrComponent}": return (`,
`+ <${componentName}`,
`+ {...{ kcContext, i18n, classes }}`,
`+ Template={Template}`,
`+ doUseDefaultCss={true}`,
...(!componentCode.includes(userProfileFormFieldComponentName)
? []
: [
`+ ${userProfileFormFieldComponentName}={${userProfileFormFieldComponentName}}`,
`+ doMakeUserConfirmPassword={doMakeUserConfirmPassword}`
]),
`+ />`,
`+ );`,
` default: return <Fallback /* .. */ />;`,
` }`,
` })()}`,
` </Suspense>`,
` );`,
` }`
].map(line => {
if (line.startsWith("+")) {
return chalk.green(line);
}
if (line.startsWith("-")) {
return chalk.red(line);
}
return chalk.grey(line);
}),
chalk.grey("```")
].join("\n")
);
}

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

@ -0,0 +1,47 @@
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 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({ "dirPath": srcDirPath, "returnedPathsType": "relative to dirPath" })
.map(fileRelativePath => {
for (const themeSrcDirBasename of themeSrcDirBasenames) {
const split = fileRelativePath.split(themeSrcDirBasename);
if (split.length === 2) {
return pathJoin(srcDirPath, split[0] + themeSrcDirBasename);
}
}
return undefined;
})
.filter(exclude(undefined))[0];
if (themeSrcDirPath !== undefined) {
return { themeSrcDirPath };
}
for (const themeType of [...themeTypes, "email"]) {
if (!fs.existsSync(pathJoin(srcDirPath, themeType))) {
continue;
}
return { "themeSrcDirPath": srcDirPath };
}
console.error(
[
"Can't locate your theme source directory. It should be either: ",
"src/ or src/keycloak-theme or src/keycloak_theme.",
"Example in the starter: https://github.com/keycloakify/keycloakify-starter/tree/main/src/keycloak-theme"
].join("\n")
);
process.exit(-1);
}

View File

@ -1 +0,0 @@
export * from "./initialize-account-theme";

View File

@ -1,94 +0,0 @@
import type { BuildContext } from "../shared/buildContext";
import cliSelect from "cli-select";
import chalk from "chalk";
import { join as pathJoin, relative as pathRelative } from "path";
import * as fs from "fs";
import { updateAccountThemeImplementationInConfig } from "./updateAccountThemeImplementationInConfig";
import { command as updateKcGenCommand } from "../update-kc-gen";
import { maybeDelegateCommandToCustomHandler } from "../shared/customHandler_delegate";
import { exitIfUncommittedChanges } from "../shared/exitIfUncommittedChanges";
import { getThisCodebaseRootDirPath } from "../tools/getThisCodebaseRootDirPath";
export async function command(params: { buildContext: BuildContext }) {
const { buildContext } = params;
const { hasBeenHandled } = await maybeDelegateCommandToCustomHandler({
commandName: "initialize-account-theme",
buildContext
});
if (hasBeenHandled) {
return;
}
const accountThemeSrcDirPath = pathJoin(buildContext.themeSrcDirPath, "account");
exitIfUncommittedChanges({
projectDirPath: buildContext.projectDirPath
});
const { value: accountThemeType } = await cliSelect({
values: ["Single-Page" as const, "Multi-Page" as const]
}).catch(() => {
process.exit(-1);
});
switch (accountThemeType) {
case "Multi-Page":
{
if (
fs.existsSync(accountThemeSrcDirPath) &&
fs.readdirSync(accountThemeSrcDirPath).length > 0
) {
console.warn(
chalk.red(
`There is already a ${pathRelative(
process.cwd(),
accountThemeSrcDirPath
)} directory in your project. Aborting.`
)
);
process.exit(-1);
}
fs.cpSync(
pathJoin(
getThisCodebaseRootDirPath(),
"src",
"bin",
"initialize-account-theme",
"multi-page-boilerplate"
),
accountThemeSrcDirPath,
{ recursive: true }
);
}
break;
case "Single-Page":
{
const { initializeSpa } = await import("../shared/initializeSpa");
await initializeSpa({
themeType: "account",
buildContext
});
}
break;
}
updateAccountThemeImplementationInConfig({ buildContext, accountThemeType });
await updateKcGenCommand({
buildContext: {
...buildContext,
implementedThemeTypes: {
...buildContext.implementedThemeTypes,
account: {
isImplemented: true,
type: accountThemeType
}
}
}
});
}

View File

@ -1,12 +0,0 @@
/* eslint-disable @typescript-eslint/ban-types */
import type { ExtendKcContext } from "keycloakify/account";
import type { KcEnvName, ThemeName } from "../kc.gen";
export type KcContextExtension = {
themeName: ThemeName;
properties: Record<KcEnvName, string> & {};
};
export type KcContextExtensionPerPage = {};
export type KcContext = ExtendKcContext<KcContextExtension, KcContextExtensionPerPage>;

View File

@ -1,25 +0,0 @@
import { Suspense } from "react";
import type { ClassKey } from "keycloakify/account";
import type { KcContext } from "./KcContext";
import { useI18n } from "./i18n";
import DefaultPage from "keycloakify/account/DefaultPage";
import Template from "keycloakify/account/Template";
export default function KcPage(props: { kcContext: KcContext }) {
const { kcContext } = props;
const { i18n } = useI18n({ kcContext });
return (
<Suspense>
{(() => {
switch (kcContext.pageId) {
default:
return <DefaultPage kcContext={kcContext} i18n={i18n} classes={classes} Template={Template} doUseDefaultCss={true} />;
}
})()}
</Suspense>
);
}
const classes = {} satisfies { [key in ClassKey]?: string };

View File

@ -1,38 +0,0 @@
import type { DeepPartial } from "keycloakify/tools/DeepPartial";
import type { KcContext } from "./KcContext";
import { createGetKcContextMock } from "keycloakify/account/KcContext";
import type { KcContextExtension, KcContextExtensionPerPage } from "./KcContext";
import KcPage from "./KcPage";
import { themeNames, kcEnvDefaults } from "../kc.gen";
const kcContextExtension: KcContextExtension = {
themeName: themeNames[0],
properties: {
...kcEnvDefaults
}
};
const kcContextExtensionPerPage: KcContextExtensionPerPage = {};
export const { getKcContextMock } = createGetKcContextMock({
kcContextExtension,
kcContextExtensionPerPage,
overrides: {},
overridesPerPage: {}
});
export function createKcPageStory<PageId extends KcContext["pageId"]>(params: { pageId: PageId }) {
const { pageId } = params;
function KcPageStory(props: { kcContext?: DeepPartial<Extract<KcContext, { pageId: PageId }>> }) {
const { kcContext: overrides } = props;
const kcContextMock = getKcContextMock({
pageId,
overrides
});
return <KcPage kcContext={kcContextMock} />;
}
return { KcPageStory };
}

View File

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

View File

@ -1,101 +0,0 @@
import { join as pathJoin } from "path";
import { assert, type Equals, is } from "tsafe/assert";
import type { BuildContext } from "../shared/buildContext";
import * as fs from "fs";
import chalk from "chalk";
import { z } from "zod";
import { id } from "tsafe/id";
export type BuildContextLike = {
bundler: BuildContext["bundler"];
projectDirPath: string;
packageJsonFilePath: string;
};
assert<BuildContext extends BuildContextLike ? true : false>();
export function updateAccountThemeImplementationInConfig(params: {
buildContext: BuildContextLike;
accountThemeType: "Single-Page" | "Multi-Page";
}) {
const { buildContext, accountThemeType } = params;
switch (buildContext.bundler) {
case "vite":
{
const viteConfigPath = pathJoin(
buildContext.projectDirPath,
"vite.config.ts"
);
if (!fs.existsSync(viteConfigPath)) {
console.log(
chalk.bold(
`You must manually set the accountThemeImplementation to "${accountThemeType}" in your vite config`
)
);
break;
}
const viteConfigContent = fs
.readFileSync(viteConfigPath)
.toString("utf8");
const modifiedViteConfigContent = viteConfigContent.replace(
/accountThemeImplementation\s*:\s*"none"/,
`accountThemeImplementation: "${accountThemeType}"`
);
if (modifiedViteConfigContent === viteConfigContent) {
console.log(
chalk.bold(
`You must manually set the accountThemeImplementation to "${accountThemeType}" in your vite.config.ts`
)
);
break;
}
fs.writeFileSync(viteConfigPath, modifiedViteConfigContent);
}
break;
case "webpack":
{
const parsedPackageJson = (() => {
type ParsedPackageJson = {
keycloakify: Record<string, unknown>;
};
const zParsedPackageJson = (() => {
type TargetType = ParsedPackageJson;
const zTargetType = z.object({
keycloakify: z.record(z.unknown())
});
assert<Equals<z.infer<typeof zTargetType>, TargetType>>();
return id<z.ZodType<TargetType>>(zTargetType);
})();
const parsedPackageJson = JSON.parse(
fs.readFileSync(buildContext.packageJsonFilePath).toString("utf8")
);
zParsedPackageJson.parse(parsedPackageJson);
assert(is<ParsedPackageJson>(parsedPackageJson));
return parsedPackageJson;
})();
parsedPackageJson.keycloakify.accountThemeImplementation =
accountThemeType;
fs.writeFileSync(
buildContext.packageJsonFilePath,
Buffer.from(JSON.stringify(parsedPackageJson, undefined, 4), "utf8")
);
}
break;
}
}

View File

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

View File

@ -1,156 +1,62 @@
import type { BuildContext } from "./shared/buildContext";
import cliSelect from "cli-select";
import { maybeDelegateCommandToCustomHandler } from "./shared/customHandler_delegate";
import { exitIfUncommittedChanges } from "./shared/exitIfUncommittedChanges";
#!/usr/bin/env node
import { dirname as pathDirname, join as pathJoin, relative as pathRelative } from "path";
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 { assert, is, type Equals } from "tsafe/assert";
import { id } from "tsafe/id";
import { addSyncExtensionsToPostinstallScript } from "./shared/addSyncExtensionsToPostinstallScript";
import { getIsPrettierAvailable, runPrettier } from "./tools/runPrettier";
import { npmInstall } from "./tools/npmInstall";
import * as child_process from "child_process";
import { z } from "zod";
import chalk from "chalk";
import { getLogger } from "./tools/logger";
import { getThemeSrcDirPath } from "./getSrcDirPath";
export async function command(params: { buildContext: BuildContext }) {
const { buildContext } = params;
export async function main() {
const projectDirPath = process.cwd();
const { hasBeenHandled } = await maybeDelegateCommandToCustomHandler({
commandName: "initialize-email-theme",
buildContext
const { isSilent } = readBuildOptions({
projectDirPath,
"processArgv": process.argv.slice(2)
});
if (hasBeenHandled) {
return;
}
const logger = getLogger({ isSilent });
exitIfUncommittedChanges({
projectDirPath: buildContext.projectDirPath
const { themeSrcDirPath } = getThemeSrcDirPath({
projectDirPath
});
const emailThemeSrcDirPath = pathJoin(buildContext.themeSrcDirPath, "email");
const emailThemeSrcDirPath = pathJoin(themeSrcDirPath, "email");
if (
fs.existsSync(emailThemeSrcDirPath) &&
fs.readdirSync(emailThemeSrcDirPath).length > 0
) {
console.warn(
chalk.red(
`There is already a ${pathRelative(
process.cwd(),
emailThemeSrcDirPath
)} directory in your project. Aborting.`
)
);
if (fs.existsSync(emailThemeSrcDirPath)) {
logger.warn(`There is already a ${pathRelative(process.cwd(), emailThemeSrcDirPath)} directory in your project. Aborting.`);
process.exit(-1);
}
const { value: emailThemeType } = await cliSelect({
values: [
"native (FreeMarker)" as const,
"Another email templating solution" as const
]
}).catch(() => {
process.exit(-1);
const { keycloakVersion } = await promptKeycloakVersion();
const builtinKeycloakThemeTmpDirPath = pathJoin(emailThemeSrcDirPath, "..", "tmp_xIdP3_builtin_keycloak_theme");
await downloadBuiltinKeycloakTheme({
projectDirPath,
keycloakVersion,
"destDirPath": builtinKeycloakThemeTmpDirPath
});
if (emailThemeType === "Another email templating solution") {
console.log(
[
"There is currently no automated support for keycloakify-email, it has to be done manually, see documentation:",
"https://docs.keycloakify.dev/theme-types/email-theme"
].join("\n")
);
process.exit(0);
}
const parsedPackageJson = (() => {
type ParsedPackageJson = {
scripts?: Record<string, string | undefined>;
dependencies?: Record<string, string | undefined>;
devDependencies?: Record<string, string | undefined>;
};
const zParsedPackageJson = (() => {
type TargetType = ParsedPackageJson;
const zTargetType = z.object({
scripts: z.record(z.union([z.string(), z.undefined()])).optional(),
dependencies: z.record(z.union([z.string(), z.undefined()])).optional(),
devDependencies: z.record(z.union([z.string(), z.undefined()])).optional()
});
assert<Equals<z.infer<typeof zTargetType>, TargetType>>;
return id<z.ZodType<TargetType>>(zTargetType);
})();
const parsedPackageJson = JSON.parse(
fs.readFileSync(buildContext.packageJsonFilePath).toString("utf8")
);
zParsedPackageJson.parse(parsedPackageJson);
assert(is<ParsedPackageJson>(parsedPackageJson));
return parsedPackageJson;
})();
addSyncExtensionsToPostinstallScript({
parsedPackageJson,
buildContext
transformCodebase({
"srcDirPath": pathJoin(builtinKeycloakThemeTmpDirPath, "base", "email"),
"destDirPath": emailThemeSrcDirPath
});
const moduleName = `@keycloakify/email-native`;
const [version] = ((): string[] => {
const cmdOutput = child_process
.execSync(`npm show ${moduleName} versions --json`)
.toString("utf8")
.trim();
const versions = JSON.parse(cmdOutput) as string | string[];
// NOTE: Bug in some older npm versions
if (typeof versions === "string") {
return [versions];
}
return versions;
})()
.reverse()
.filter(version => !version.includes("-"));
assert(version !== undefined);
(parsedPackageJson.dependencies ??= {})[moduleName] = `~${version}`;
if (parsedPackageJson.devDependencies !== undefined) {
delete parsedPackageJson.devDependencies[moduleName];
}
{
let sourceCode = JSON.stringify(parsedPackageJson, undefined, 2);
const themePropertyFilePath = pathJoin(emailThemeSrcDirPath, "theme.properties");
if (await getIsPrettierAvailable()) {
sourceCode = await runPrettier({
sourceCode,
filePath: buildContext.packageJsonFilePath
});
}
fs.writeFileSync(
buildContext.packageJsonFilePath,
Buffer.from(sourceCode, "utf8")
);
fs.writeFileSync(themePropertyFilePath, Buffer.from(`parent=base\n${fs.readFileSync(themePropertyFilePath).toString("utf8")}`, "utf8"));
}
await npmInstall({
packageJsonDirPath: pathDirname(buildContext.packageJsonFilePath)
});
logger.log(`${pathRelative(process.cwd(), emailThemeSrcDirPath)} ready to be customized, feel free to remove every file you do not customize`);
console.log(chalk.green("Email theme initialized."));
fs.rmSync(builtinKeycloakThemeTmpDirPath, { "recursive": true, "force": true });
}
if (require.main === module) {
main();
}

View File

@ -0,0 +1,138 @@
import { assert } from "tsafe/assert";
import { id } from "tsafe/id";
import { parse as urlParse } from "url";
import { typeGuard } from "tsafe/typeGuard";
import { symToStr } from "tsafe/symToStr";
import { bundlers, getParsedPackageJson, type Bundler } from "./parsedPackageJson";
import { 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 = {
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 function readBuildOptions(params: { projectDirPath: string; processArgv: string[] }): BuildOptions {
const { projectDirPath, processArgv } = params;
const { isSilentCliParamProvided } = (() => {
const argv = parseArgv(processArgv);
return {
"isSilentCliParamProvided": typeof argv["silent"] === "boolean" ? argv["silent"] : false
};
})();
const parsedPackageJson = getParsedPackageJson({ projectDirPath });
const { name, keycloakify = {}, version, homepage } = parsedPackageJson;
const { extraThemeProperties, groupId, artifactId, bundler, keycloakVersionDefaultAssets, extraThemeNames = [] } = keycloakify ?? {};
const themeName =
keycloakify.themeName ??
name
.replace(/^@(.*)/, "$1")
.split("/")
.join("-");
return {
themeName,
extraThemeNames,
"bundler": (() => {
const { KEYCLOAKIFY_BUNDLER } = process.env;
assert(
typeGuard<Bundler | undefined>(KEYCLOAKIFY_BUNDLER, [undefined, ...id<readonly string[]>(bundlers)].includes(KEYCLOAKIFY_BUNDLER)),
`${symToStr({ KEYCLOAKIFY_BUNDLER })} should be one of ${bundlers.join(", ")}`
);
return KEYCLOAKIFY_BUNDLER ?? bundler ?? "keycloakify";
})(),
"artifactId": process.env.KEYCLOAKIFY_ARTIFACT_ID ?? artifactId ?? `${themeName}-keycloak-theme`,
"groupId": (() => {
const fallbackGroupId = `${themeName}.keycloak`;
return (
process.env.KEYCLOAKIFY_GROUP_ID ??
groupId ??
(!homepage
? fallbackGroupId
: urlParse(homepage)
.host?.replace(/:[0-9]+$/, "")
?.split(".")
.reverse()
.join(".") ?? fallbackGroupId) + ".keycloak"
);
})(),
"themeVersion": process.env.KEYCLOAKIFY_THEME_VERSION ?? process.env.KEYCLOAKIFY_VERSION ?? version ?? "0.0.0",
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;
}
const out = url.pathname.replace(/([^/])$/, "$1/");
return out === "/" ? undefined : out;
})()
};
}

View File

@ -1,265 +0,0 @@
import { assert, type Equals } from "tsafe/assert";
import type {
KeycloakAccountV1Version,
KeycloakThemeAdditionalInfoExtensionVersion
} from "./extensionVersions";
import { join as pathJoin, dirname as pathDirname } from "path";
import { transformCodebase } from "../../tools/transformCodebase";
import type { BuildContext } from "../../shared/buildContext";
import * as fs from "fs/promises";
import {
generatePom,
BuildContextLike as BuildContextLike_generatePom
} from "./generatePom";
import { readFileSync } from "fs";
import { isInside } from "../../tools/isInside";
import child_process from "child_process";
import { rmSync } from "../../tools/fs.rmSync";
import { existsAsync } from "../../tools/fs.existsAsync";
export type BuildContextLike = BuildContextLike_generatePom & {
keycloakifyBuildDirPath: string;
themeNames: string[];
artifactId: string;
themeVersion: string;
cacheDirPath: string;
implementedThemeTypes: BuildContext["implementedThemeTypes"];
};
assert<BuildContext extends BuildContextLike ? true : false>();
export async function buildJar(params: {
jarFileBasename: string;
keycloakAccountV1Version: KeycloakAccountV1Version;
keycloakThemeAdditionalInfoExtensionVersion: KeycloakThemeAdditionalInfoExtensionVersion;
resourcesDirPath: string;
doesImplementAccountV1Theme: boolean;
buildContext: BuildContextLike;
}): Promise<void> {
const {
jarFileBasename,
keycloakAccountV1Version,
keycloakThemeAdditionalInfoExtensionVersion,
resourcesDirPath,
doesImplementAccountV1Theme,
buildContext
} = params;
const keycloakifyBuildCacheDirPath = pathJoin(
buildContext.cacheDirPath,
"maven",
jarFileBasename.replace(".jar", "")
);
const tmpResourcesDirPath = pathJoin(
keycloakifyBuildCacheDirPath,
"src",
"main",
"resources"
);
rmSync(tmpResourcesDirPath, { recursive: true, force: true });
transformCodebase({
srcDirPath: resourcesDirPath,
destDirPath: tmpResourcesDirPath,
transformSourceCode:
!doesImplementAccountV1Theme || keycloakAccountV1Version !== null
? undefined
: (params: {
fileRelativePath: string;
sourceCode: Buffer;
}): { modifiedSourceCode: Buffer } | undefined => {
const { fileRelativePath, sourceCode } = params;
if (
isInside({
dirPath: pathJoin("theme", "account-v1"),
filePath: fileRelativePath
})
) {
return undefined;
}
for (const themeName of buildContext.themeNames) {
if (
fileRelativePath ===
pathJoin("theme", themeName, "account", "theme.properties")
) {
const modifiedSourceCode = Buffer.from(
sourceCode
.toString("utf8")
.replace(`parent=account-v1`, "parent=keycloak"),
"utf8"
);
assert(
Buffer.compare(modifiedSourceCode, sourceCode) !== 0
);
return { modifiedSourceCode };
}
}
return { modifiedSourceCode: sourceCode };
}
});
{
const filePath = pathJoin(
tmpResourcesDirPath,
"META-INF",
"keycloak-themes.json"
);
await fs.mkdir(pathDirname(filePath));
await fs.writeFile(
filePath,
Buffer.from(
JSON.stringify(
{
themes: await (async () => {
const dirPath = pathJoin(tmpResourcesDirPath, "theme");
const themeNames = (await fs.readdir(dirPath)).sort(
(a, b) => {
const indexA = buildContext.themeNames.indexOf(a);
const indexB = buildContext.themeNames.indexOf(b);
const orderA = indexA === -1 ? Infinity : indexA;
const orderB = indexB === -1 ? Infinity : indexB;
return orderA - orderB;
}
);
return Promise.all(
themeNames.map(async themeName => {
const types = await fs.readdir(
pathJoin(dirPath, themeName)
);
return {
name: themeName,
types
};
})
);
})()
},
null,
2
),
"utf8"
)
);
}
route_legacy_pages: {
if (!buildContext.implementedThemeTypes.login.isImplemented) {
break route_legacy_pages;
}
await Promise.all(
(["register.ftl", "login-update-profile.ftl"] as const)
.map(pageId =>
buildContext.themeNames.map(async themeName => {
const ftlFilePath = pathJoin(
tmpResourcesDirPath,
"theme",
themeName,
"login",
pageId
);
// NOTE: https://github.com/keycloakify/keycloakify/issues/665
if (!(await existsAsync(ftlFilePath))) {
return;
}
const ftlFileContent = readFileSync(ftlFilePath).toString("utf8");
const ftlFileBasename = (() => {
switch (pageId) {
case "register.ftl":
return "register-user-profile.ftl";
case "login-update-profile.ftl":
return "update-user-profile.ftl";
}
assert<Equals<typeof pageId, never>>(false);
})();
const modifiedFtlFileContent = ftlFileContent.replace(
`"ftlTemplateFileName": "${pageId}"`,
`"ftlTemplateFileName": "${ftlFileBasename}"`
);
assert(modifiedFtlFileContent !== ftlFileContent);
await fs.writeFile(
pathJoin(pathDirname(ftlFilePath), ftlFileBasename),
Buffer.from(modifiedFtlFileContent, "utf8")
);
})
)
.flat()
);
}
{
const { pomFileCode } = generatePom({
buildContext,
keycloakAccountV1Version,
keycloakThemeAdditionalInfoExtensionVersion
});
await fs.writeFile(
pathJoin(keycloakifyBuildCacheDirPath, "pom.xml"),
Buffer.from(pomFileCode, "utf8")
);
}
{
const mvnBuildCmd = `mvn clean install -Dmaven.repo.local="${pathJoin(keycloakifyBuildCacheDirPath, ".m2")}"`;
await new Promise<void>((resolve, reject) =>
child_process.exec(
mvnBuildCmd,
{ cwd: keycloakifyBuildCacheDirPath },
error => {
if (error !== null) {
console.error(
[
`Build jar failed: ${JSON.stringify(
{
jarFileBasename,
keycloakAccountV1Version,
keycloakThemeAdditionalInfoExtensionVersion
},
null,
2
)}`,
"Try running the following command to debug the issue (you are probably under a restricted network and you need to configure your proxy):",
`cd ${keycloakifyBuildCacheDirPath} && ${mvnBuildCmd}`
].join("\n")
);
reject(error);
return;
}
resolve();
}
)
);
}
await fs.rename(
pathJoin(
keycloakifyBuildCacheDirPath,
"target",
`${buildContext.artifactId}-${buildContext.themeVersion}.jar`
),
pathJoin(buildContext.keycloakifyBuildDirPath, jarFileBasename)
);
}

View File

@ -1,68 +0,0 @@
import { assert } from "tsafe/assert";
import {
keycloakAccountV1Versions,
keycloakThemeAdditionalInfoExtensionVersions
} from "./extensionVersions";
import { getKeycloakVersionRangeForJar } from "./getKeycloakVersionRangeForJar";
import { buildJar, BuildContextLike as BuildContextLike_buildJar } from "./buildJar";
import type { BuildContext } from "../../shared/buildContext";
export type BuildContextLike = BuildContextLike_buildJar & {
projectDirPath: string;
keycloakifyBuildDirPath: string;
implementedThemeTypes: BuildContext["implementedThemeTypes"];
jarTargets: BuildContext["jarTargets"];
};
assert<BuildContext extends BuildContextLike ? true : false>();
export async function buildJars(params: {
resourcesDirPath: string;
buildContext: BuildContextLike;
}): Promise<void> {
const { resourcesDirPath, buildContext } = params;
const doesImplementAccountV1Theme =
buildContext.implementedThemeTypes.account.isImplemented &&
buildContext.implementedThemeTypes.account.type === "Multi-Page";
await Promise.all(
keycloakAccountV1Versions
.map(keycloakAccountV1Version =>
keycloakThemeAdditionalInfoExtensionVersions.map(
keycloakThemeAdditionalInfoExtensionVersion => {
const keycloakVersionRange = getKeycloakVersionRangeForJar({
doesImplementAccountV1Theme,
keycloakAccountV1Version,
keycloakThemeAdditionalInfoExtensionVersion
});
if (keycloakVersionRange === undefined) {
return undefined;
}
const jarTarget = buildContext.jarTargets.find(
jarTarget =>
jarTarget.keycloakVersionRange === keycloakVersionRange
);
if (jarTarget === undefined) {
return undefined;
}
const { jarFileBasename } = jarTarget;
return buildJar({
jarFileBasename,
keycloakAccountV1Version,
keycloakThemeAdditionalInfoExtensionVersion,
resourcesDirPath,
doesImplementAccountV1Theme,
buildContext
});
}
)
)
.flat()
);
}

View File

@ -1,17 +0,0 @@
// NOTE: v0.5 is a dummy version.
export const keycloakAccountV1Versions = [null, "0.3", "0.4", "0.6"] as const;
/**
* https://central.sonatype.com/artifact/io.phasetwo.keycloak/keycloak-account-v1
* https://github.com/p2-inc/keycloak-account-v1
*/
export type KeycloakAccountV1Version = (typeof keycloakAccountV1Versions)[number];
export const keycloakThemeAdditionalInfoExtensionVersions = [null, "1.1.5"] as const;
/**
* https://central.sonatype.com/artifact/dev.jcputney/keycloak-theme-additional-info-extension
* https://github.com/jcputney/keycloak-theme-additional-info-extension
* */
export type KeycloakThemeAdditionalInfoExtensionVersion =
(typeof keycloakThemeAdditionalInfoExtensionVersions)[number];

View File

@ -1,94 +0,0 @@
import { assert } from "tsafe/assert";
import type { BuildContext } from "../../shared/buildContext";
import type {
KeycloakAccountV1Version,
KeycloakThemeAdditionalInfoExtensionVersion
} from "./extensionVersions";
export type BuildContextLike = {
groupId: string;
artifactId: string;
themeVersion: string;
};
assert<BuildContext extends BuildContextLike ? true : false>();
export function generatePom(params: {
keycloakAccountV1Version: KeycloakAccountV1Version;
keycloakThemeAdditionalInfoExtensionVersion: KeycloakThemeAdditionalInfoExtensionVersion;
buildContext: BuildContextLike;
}) {
const {
keycloakAccountV1Version,
keycloakThemeAdditionalInfoExtensionVersion,
buildContext
} = params;
const { pomFileCode } = (function generatePomFileCode(): {
pomFileCode: string;
} {
const pomFileCode = [
`<?xml version="1.0"?>`,
`<project xmlns="http://maven.apache.org/POM/4.0.0"`,
` xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"`,
` xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">`,
` <modelVersion>4.0.0</modelVersion>`,
` <groupId>${buildContext.groupId}</groupId>`,
` <artifactId>${buildContext.artifactId}</artifactId>`,
` <version>${buildContext.themeVersion}</version>`,
` <name>${buildContext.artifactId}</name>`,
` <description />`,
` <packaging>jar</packaging>`,
` <properties>`,
` <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>`,
` </properties>`,
...(keycloakAccountV1Version !== null ||
keycloakThemeAdditionalInfoExtensionVersion !== null
? [
` <build>`,
` <plugins>`,
` <plugin>`,
` <groupId>org.apache.maven.plugins</groupId>`,
` <artifactId>maven-shade-plugin</artifactId>`,
` <version>3.5.1</version>`,
` <executions>`,
` <execution>`,
` <phase>package</phase>`,
` <goals>`,
` <goal>shade</goal>`,
` </goals>`,
` </execution>`,
` </executions>`,
` </plugin>`,
` </plugins>`,
` </build>`,
` <dependencies>`,
...(keycloakAccountV1Version !== null
? [
` <dependency>`,
` <groupId>io.phasetwo.keycloak</groupId>`,
` <artifactId>keycloak-account-v1</artifactId>`,
` <version>${keycloakAccountV1Version}</version>`,
` </dependency>`
]
: []),
...(keycloakThemeAdditionalInfoExtensionVersion !== null
? [
` <dependency>`,
` <groupId>dev.jcputney</groupId>`,
` <artifactId>keycloak-theme-additional-info-extension</artifactId>`,
` <version>${keycloakThemeAdditionalInfoExtensionVersion}</version>`,
` </dependency>`
]
: []),
` </dependencies>`
]
: []),
`</project>`
].join("\n");
return { pomFileCode };
})();
return { pomFileCode };
}

View File

@ -1,96 +0,0 @@
import { assert, type Equals } from "tsafe/assert";
import type {
KeycloakAccountV1Version,
KeycloakThemeAdditionalInfoExtensionVersion
} from "./extensionVersions";
import type { KeycloakVersionRange } from "../../shared/KeycloakVersionRange";
export function getKeycloakVersionRangeForJar(params: {
doesImplementAccountV1Theme: boolean;
keycloakAccountV1Version: KeycloakAccountV1Version;
keycloakThemeAdditionalInfoExtensionVersion: KeycloakThemeAdditionalInfoExtensionVersion;
}): KeycloakVersionRange | undefined {
const {
keycloakAccountV1Version,
keycloakThemeAdditionalInfoExtensionVersion,
doesImplementAccountV1Theme
} = params;
if (doesImplementAccountV1Theme) {
const keycloakVersionRange = (() => {
switch (keycloakAccountV1Version) {
case null:
switch (keycloakThemeAdditionalInfoExtensionVersion) {
case null:
return "21-and-below" as const;
case "1.1.5":
return undefined;
}
assert<
Equals<typeof keycloakThemeAdditionalInfoExtensionVersion, never>
>(false);
case "0.3":
switch (keycloakThemeAdditionalInfoExtensionVersion) {
case null:
return undefined;
case "1.1.5":
return "23" as const;
}
assert<
Equals<typeof keycloakThemeAdditionalInfoExtensionVersion, never>
>(false);
case "0.4":
switch (keycloakThemeAdditionalInfoExtensionVersion) {
case null:
return undefined;
case "1.1.5":
return "24" as const;
}
assert<
Equals<typeof keycloakThemeAdditionalInfoExtensionVersion, never>
>(false);
case "0.6":
switch (keycloakThemeAdditionalInfoExtensionVersion) {
case null:
return "26-and-above" as const;
case "1.1.5":
return "25" as const;
}
}
assert<Equals<typeof keycloakAccountV1Version, never>>(false);
})();
assert<
Equals<
typeof keycloakVersionRange,
KeycloakVersionRange.WithAccountV1Theme | undefined
>
>();
return keycloakVersionRange;
} else {
const keycloakVersionRange = (() => {
if (keycloakAccountV1Version !== null) {
return undefined;
}
switch (keycloakThemeAdditionalInfoExtensionVersion) {
case null:
return "all-other-versions";
case "1.1.5":
return "22-to-25";
}
assert<Equals<typeof keycloakThemeAdditionalInfoExtensionVersion, never>>(
false
);
})();
assert<
Equals<
typeof keycloakVersionRange,
KeycloakVersionRange.WithoutAccountV1Theme | undefined
>
>();
return keycloakVersionRange;
}
}

View File

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

View File

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

View File

@ -0,0 +1,717 @@
<script>const _=
<#assign pageId="PAGE_ID_xIgLsPgGId9D8e">
(()=>{
const out = ${ftl_object_to_js_code_declaring_an_object(.data_model, [])?no_esc};
out["msg"]= function(){ throw new Error("use import { useKcMessage } from 'keycloakify'"); };
out["advancedMsg"]= function(){ throw new Error("use import { useKcMessage } from 'keycloakify'"); };
out["messagesPerField"]= {
<#assign fieldNames = [ FIELD_NAMES_eKsIY4ZsZ4xeM ]>
<#attempt>
<#if profile?? && profile.attributes?? && profile.attributes?is_enumerable>
<#list profile.attributes as attribute>
<#if fieldNames?seq_contains(attribute.name)>
<#continue>
</#if>
<#assign fieldNames += [attribute.name]>
</#list>
</#if>
<#recover>
</#attempt>
"printIfExists": function (fieldName, text) {
<#if !messagesPerField?? || !(messagesPerField?is_hash)>
throw new Error("You're not supposed to use messagesPerField.printIfExists in this page");
<#else>
<#list fieldNames as fieldName>
if(fieldName === "${fieldName}" ){
<#-- https://github.com/keycloakify/keycloakify/pull/359 Compat with Keycloak prior v12 -->
<#if !messagesPerField.existsError??>
<#-- https://github.com/keycloakify/keycloakify/pull/218 -->
<#if ('${fieldName}' == 'username' || '${fieldName}' == 'password') && pageId != 'register.ftl' && pageId != 'register-user-profile.ftl'>
<#assign doExistMessageForUsernameOrPassword = "">
<#attempt>
<#assign doExistMessageForUsernameOrPassword = messagesPerField.exists('username')>
<#recover>
<#assign doExistMessageForUsernameOrPassword = true>
</#attempt>
<#if !doExistMessageForUsernameOrPassword>
<#attempt>
<#assign doExistMessageForUsernameOrPassword = messagesPerField.exists('password')>
<#recover>
<#assign doExistMessageForUsernameOrPassword = true>
</#attempt>
</#if>
return <#if doExistMessageForUsernameOrPassword>text<#else>undefined</#if>;
<#else>
<#assign doExistMessageForField = "">
<#attempt>
<#assign doExistMessageForField = messagesPerField.exists('${fieldName}')>
<#recover>
<#assign doExistMessageForField = true>
</#attempt>
return <#if doExistMessageForField>text<#else>undefined</#if>;
</#if>
<#else>
<#-- https://github.com/keycloakify/keycloakify/pull/218 -->
<#if ('${fieldName}' == 'username' || '${fieldName}' == 'password') && pageId != 'register.ftl' && pageId != 'register-user-profile.ftl'>
<#assign doExistErrorOnUsernameOrPassword = "">
<#attempt>
<#assign doExistErrorOnUsernameOrPassword = messagesPerField.existsError('username', 'password')>
<#recover>
<#assign doExistErrorOnUsernameOrPassword = true>
</#attempt>
<#if doExistErrorOnUsernameOrPassword>
return text;
<#else>
<#assign doExistMessageForField = "">
<#attempt>
<#assign doExistMessageForField = messagesPerField.exists('${fieldName}')>
<#recover>
<#assign doExistMessageForField = true>
</#attempt>
return <#if doExistMessageForField>text<#else>undefined</#if>;
</#if>
<#else>
<#assign doExistMessageForField = "">
<#attempt>
<#assign doExistMessageForField = messagesPerField.exists('${fieldName}')>
<#recover>
<#assign doExistMessageForField = true>
</#attempt>
return <#if doExistMessageForField>text<#else>undefined</#if>;
</#if>
</#if>
}
</#list>
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?? || !(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}" ){
<#-- 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>
<#assign doExistMessageForField = "">
<#attempt>
<#assign doExistMessageForField = messagesPerField.exists('${fieldName}')>
<#recover>
<#assign doExistMessageForField = true>
</#attempt>
return <#if doExistMessageForField>true<#else>false</#if>;
</#if>
<#else>
<#-- https://github.com/keycloakify/keycloakify/pull/218 -->
<#if ('${fieldName}' == 'username' || '${fieldName}' == 'password') && pageId != 'register.ftl' && pageId != 'register-user-profile.ftl'>
<#assign doExistErrorOnUsernameOrPassword = "">
<#attempt>
<#assign doExistErrorOnUsernameOrPassword = messagesPerField.existsError('username', 'password')>
<#recover>
<#assign doExistErrorOnUsernameOrPassword = true>
</#attempt>
return <#if doExistErrorOnUsernameOrPassword>true<#else>false</#if>;
<#else>
<#assign doExistErrorMessageForField = "">
<#attempt>
<#assign doExistErrorMessageForField = messagesPerField.existsError('${fieldName}')>
<#recover>
<#assign doExistErrorMessageForField = true>
</#attempt>
return <#if doExistErrorMessageForField>true<#else>false</#if>;
</#if>
</#if>
}
</#list>
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?? || !(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}" ){
<#-- 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>
<#attempt>
return "${messagesPerField.get('${fieldName}')?no_esc}";
<#recover>
return "invalid field";
</#attempt>
</#if>
<#else>
<#-- https://github.com/keycloakify/keycloakify/pull/218 -->
<#if ('${fieldName}' == 'username' || '${fieldName}' == 'password') && pageId != 'register.ftl' && pageId != 'register-user-profile.ftl'>
<#assign doExistErrorOnUsernameOrPassword = "">
<#attempt>
<#assign doExistErrorOnUsernameOrPassword = messagesPerField.existsError('username', 'password')>
<#recover>
<#assign doExistErrorOnUsernameOrPassword = true>
</#attempt>
<#if doExistErrorOnUsernameOrPassword>
<#attempt>
return "${kcSanitize(msg('invalidUserMessage'))?no_esc}";
<#recover>
return "Invalid username or password.";
</#attempt>
<#else>
<#attempt>
return "${messagesPerField.get('${fieldName}')?no_esc}";
<#recover>
return "";
</#attempt>
</#if>
<#else>
<#attempt>
return "${messagesPerField.get('${fieldName}')?no_esc}";
<#recover>
return "invalid field";
</#attempt>
</#if>
</#if>
}
</#list>
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?? || !(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}" ){
<#-- 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>
<#assign doExistMessageForField = "">
<#attempt>
<#assign doExistMessageForField = messagesPerField.exists('${fieldName}')>
<#recover>
<#assign doExistMessageForField = true>
</#attempt>
return <#if doExistMessageForField>true<#else>false</#if>;
</#if>
<#else>
<#-- https://github.com/keycloakify/keycloakify/pull/218 -->
<#if ('${fieldName}' == 'username' || '${fieldName}' == 'password') && pageId != 'register.ftl' && pageId != 'register-user-profile.ftl'>
<#assign doExistErrorOnUsernameOrPassword = "">
<#attempt>
<#assign doExistErrorOnUsernameOrPassword = messagesPerField.existsError('username', 'password')>
<#recover>
<#assign doExistErrorOnUsernameOrPassword = true>
</#attempt>
return <#if doExistErrorOnUsernameOrPassword>true<#else>false</#if>;
<#else>
<#assign doExistErrorMessageForField = "">
<#attempt>
<#assign doExistErrorMessageForField = messagesPerField.exists('${fieldName}')>
<#recover>
<#assign doExistErrorMessageForField = true>
</#attempt>
return <#if doExistErrorMessageForField>true<#else>false</#if>;
</#if>
</#if>
}
</#list>
throw new Error(fieldName + "is probably runtime generated, see: https://docs.keycloakify.dev/limitations#field-names-cant-be-runtime-generated");
</#if>
}
};
<#if account??>
out["url"]["getLogoutUrl"] = function () {
<#attempt>
return "${url.getLogoutUrl()}";
<#recover>
</#attempt>
};
</#if>
out["keycloakifyVersion"] = "KEYCLOAKIFY_VERSION_xEdKd3xEdr";
out["themeVersion"] = "KEYCLOAKIFY_THEME_VERSION_sIgKd3xEdr3dx";
out["themeType"] = "KEYCLOAKIFY_THEME_TYPE_dExKd3xEdr";
out["themeName"] = "KEYCLOAKIFY_THEME_NAME_cXxKd3xEer";
out["pageId"] = "${pageId}";
try {
out["url"]["resourcesCommonPath"] = out["url"]["resourcesPath"] + "/" + "RESOURCES_COMMON_cLsLsMrtDkpVv";
} catch(error) {
}
return out;
})()
<#function ftl_object_to_js_code_declaring_an_object object path>
<#local isHash = "">
<#attempt>
<#local isHash = object?is_hash || object?is_hash_ex>
<#recover>
<#return "ABORT: Can't evaluate if " + path?join(".") + " is hash">
</#attempt>
<#if isHash>
<#if path?size gt 10>
<#return "ABORT: Too many recursive calls, path: " + path?join(".")>
</#if>
<#local keys = "">
<#attempt>
<#local keys = object?keys>
<#recover>
<#return "ABORT: We can't list keys on this object">
</#attempt>
<#local out_seq = []>
<#list keys as key>
<#if ["class","declaredConstructors","superclass","declaringClass" ]?seq_contains(key) >
<#continue>
</#if>
<#if
(
["loginUpdatePasswordUrl", "loginUpdateProfileUrl", "loginUsernameReminderUrl", "loginUpdateTotpUrl"]?seq_contains(key) &&
are_same_path(path, ["url"])
) || (
key == "updateProfileCtx" &&
are_same_path(path, [])
) || (
<#-- https://github.com/keycloakify/keycloakify/pull/65#issuecomment-991896344 (reports with saml-post-form.ftl) -->
<#-- https://github.com/keycloakify/keycloakify/issues/91#issue-1212319466 (reports with error.ftl and Kc18) -->
<#-- https://github.com/keycloakify/keycloakify/issues/109#issuecomment-1134610163 -->
<#-- https://github.com/keycloakify/keycloakify/issues/357 -->
<#-- https://github.com/keycloakify/keycloakify/discussions/406#discussioncomment-7514787 -->
key == "loginAction" &&
are_same_path(path, ["url"]) &&
["saml-post-form.ftl", "error.ftl", "info.ftl", "login-oauth-grant.ftl", "logout-confirm.ftl", "login-oauth2-device-verify-user-code.ftl"]?seq_contains(pageId) &&
!(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"]) &&
["login-idp-link-confirm.ftl", "login-idp-link-email.ftl" ]?seq_contains(pageId)
) || (
key == "identityProviderBrokerCtx" &&
are_same_path(path, []) &&
["login-idp-link-confirm.ftl", "login-idp-link-email.ftl" ]?seq_contains(pageId)
) || (
["masterAdminClient", "delegateForUpdate", "defaultRole"]?seq_contains(key) &&
are_same_path(path, ["realm"])
) || (
"error.ftl" == pageId &&
are_same_path(path, ["realm"]) &&
!["name", "displayName", "displayNameHtml", "internationalizationEnabled", "registrationEmailAsUsername" ]?seq_contains(key)
) || (
"smtpConfig" == key &&
are_same_path(path, ["realm"])
) || (
"applications.ftl" == pageId &&
is_subpath(path, ["applications", "applications"]) &&
(
key == "realm" ||
key == "container"
)
) || (
are_same_path(path, ["user"]) &&
key == "delegateForUpdate"
)
>
<#local out_seq += ["/*If you need '" + path?join(".") + "." + key + "' on " + pageId + ", please submit an issue to the Keycloakify repo*/"]>
<#continue>
</#if>
<#-- https://github.com/keycloakify/keycloakify/discussions/406 -->
<#if (
["register.ftl", "info.ftl", "login.ftl", "login-update-password.ftl", "login-oauth2-device-verify-user-code.ftl"]?seq_contains(pageId) &&
key == "attemptedUsername" && are_same_path(path, ["auth"])
)>
<#attempt>
<#-- https://github.com/keycloak/keycloak/blob/3a2bf0c04bcde185e497aaa32d0bb7ab7520cf4a/themes/src/main/resources/theme/base/login/template.ftl#L63 -->
<#if !(auth?has_content && auth.showUsername() && !auth.showResetCredentials())>
<#local out_seq += ["/*If you need '" + key + "' on " + pageId + ", please submit an issue to the Keycloakify repo*/"]>
<#continue>
</#if>
<#recover>
<#local out_seq += ["/*Testing if attemptedUsername should be skipped throwed an exception */"]>
</#attempt>
</#if>
<#attempt>
<#if !object[key]??>
<#continue>
</#if>
<#recover>
<#local out_seq += ["/*Couldn't test if '" + key + "' is available on this object*/"]>
<#continue>
</#attempt>
<#local propertyValue = "">
<#attempt>
<#local propertyValue = object[key]>
<#recover>
<#local out_seq += ["/*Couldn't dereference '" + key + "' on this object*/"]>
<#continue>
</#attempt>
<#local rec_out = ftl_object_to_js_code_declaring_an_object(propertyValue, path + [ key ])>
<#if rec_out?starts_with("ABORT:")>
<#local errorMessage = rec_out?remove_beginning("ABORT:")>
<#if errorMessage != " It's a method" >
<#local out_seq += ["/*" + key + ": " + errorMessage + "*/"]>
</#if>
<#continue>
</#if>
<#local out_seq += ['"' + key + '": ' + rec_out + ","]>
</#list>
<#return (["{"] + out_seq?map(str -> ""?right_pad(4 * (path?size + 1)) + str) + [ ""?right_pad(4 * path?size) + "}"])?join("\n")>
</#if>
<#local isMethod = "">
<#attempt>
<#local isMethod = object?is_method>
<#recover>
<#return "ABORT: Can't test if it'sa method.">
</#attempt>
<#if isMethod>
<#if are_same_path(path, ["auth", "showUsername"])>
<#attempt>
<#return auth.showUsername()?c>
<#recover>
<#return "ABORT: Couldn't evaluate auth.showUsername()">
</#attempt>
</#if>
<#if are_same_path(path, ["auth", "showResetCredentials"])>
<#attempt>
<#return auth.showResetCredentials()?c>
<#recover>
<#return "ABORT: Couldn't evaluate auth.showResetCredentials()">
</#attempt>
</#if>
<#if are_same_path(path, ["auth", "showTryAnotherWayLink"])>
<#attempt>
<#return auth.showTryAnotherWayLink()?c>
<#recover>
<#return "ABORT: Couldn't evaluate auth.showTryAnotherWayLink()">
</#attempt>
</#if>
<#return "ABORT: It's a method">
</#if>
<#local isBoolean = "">
<#attempt>
<#local isBoolean = object?is_boolean>
<#recover>
<#return "ABORT: Can't test if it's a boolean">
</#attempt>
<#if isBoolean>
<#return object?c>
</#if>
<#local isEnumerable = "">
<#attempt>
<#local isEnumerable = object?is_enumerable>
<#recover>
<#return "ABORT: Can't test if it's an enumerable">
</#attempt>
<#if isEnumerable>
<#local out_seq = []>
<#local i = 0>
<#list object as array_item>
<#if !array_item??>
<#local out_seq += ["null,"]>
<#continue>
</#if>
<#local rec_out = ftl_object_to_js_code_declaring_an_object(array_item, path + [ i ])>
<#local i = i + 1>
<#if rec_out?starts_with("ABORT:")>
<#local errorMessage = rec_out?remove_beginning("ABORT:")>
<#if errorMessage != " It's a method" >
<#local out_seq += ["/*" + i?string + ": " + errorMessage + "*/"]>
</#if>
<#continue>
</#if>
<#local out_seq += [rec_out + ","]>
</#list>
<#return (["["] + out_seq?map(str -> ""?right_pad(4 * (path?size + 1)) + str) + [ ""?right_pad(4 * path?size) + "]"])?join("\n")>
</#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>
</#attempt>
<#return "ABORT: Couldn't convert into string non hash, non method, non boolean, non enumerable object">
</#function>
<#function is_subpath path searchedPath>
<#if path?size < searchedPath?size>
<#return false>
</#if>
<#local i=0>
<#list path as property>
<#if i == searchedPath?size >
<#continue>
</#if>
<#local searchedProperty=searchedPath[i]>
<#local i+= 1>
<#if searchedProperty?is_string && searchedProperty == "*">
<#continue>
</#if>
<#if searchedProperty?is_string && !property?is_string>
<#return false>
</#if>
<#if searchedProperty?is_number && !property?is_number>
<#return false>
</#if>
<#if searchedProperty?string != property?string>
<#return false>
</#if>
</#list>
<#return true>
</#function>
<#function are_same_path path searchedPath>
<#return path?size == searchedPath?size && is_subpath(path, searchedPath)>
</#function>
</script>

View File

@ -1,48 +1,36 @@
import * as cheerio from "cheerio";
import {
replaceImportsInJsCode,
BuildContextLike as BuildContextLike_replaceImportsInJsCode
} from "../replacers/replaceImportsInJsCode";
import {
replaceImportsInCssCode,
BuildContextLike as BuildContextLike_replaceImportsInCssCode
} from "../replacers/replaceImportsInCssCode";
import cheerio from "cheerio";
import { replaceImportsFromStaticInJsCode } from "../replacers/replaceImportsFromStaticInJsCode";
import { generateCssCodeToDefineGlobals } from "../replacers/replaceImportsInCssCode";
import { replaceImportsInInlineCssCode } from "../replacers/replaceImportsInInlineCssCode";
import * as fs from "fs";
import { join as pathJoin } from "path";
import type { BuildContext } from "../../shared/buildContext";
import { objectKeys } from "tsafe/objectKeys";
import { ftlValuesGlobalName } from "../ftlValuesGlobalName";
import type { BuildOptions } from "../BuildOptions";
import { assert } from "tsafe/assert";
import {
type ThemeType,
WELL_KNOWN_DIRECTORY_BASE_NAME,
KEYCLOAKIFY_SPA_DEV_SERVER_PORT
} from "../../shared/constants";
import { getThisCodebaseRootDirPath } from "../../tools/getThisCodebaseRootDirPath";
export type BuildContextLike = BuildContextLike_replaceImportsInJsCode &
BuildContextLike_replaceImportsInCssCode & {
urlPathname: string | undefined;
themeVersion: string;
kcContextExclusionsFtlCode: string | undefined;
};
export const themeTypes = ["login", "account"] as const;
assert<BuildContext extends BuildContextLike ? true : false>();
export type ThemeType = (typeof themeTypes)[number];
export type BuildOptionsLike = {
themeName: string;
themeVersion: string;
urlPathname: string | undefined;
};
assert<BuildOptions extends BuildOptionsLike ? true : false>();
export function generateFtlFilesCodeFactory(params: {
themeName: string;
indexHtmlCode: string;
buildContext: BuildContextLike;
//NOTE: Expected to be an empty object if external assets mode is enabled.
cssGlobalsToDefine: Record<string, string>;
buildOptions: BuildOptionsLike;
keycloakifyVersion: string;
themeType: ThemeType;
fieldNames: string[];
}) {
const {
themeName,
indexHtmlCode,
buildContext,
keycloakifyVersion,
themeType,
fieldNames
} = params;
const { cssGlobalsToDefine, indexHtmlCode, buildOptions, keycloakifyVersion, themeType, fieldNames } = params;
const $ = cheerio.load(indexHtmlCode);
@ -52,10 +40,7 @@ export function generateFtlFilesCodeFactory(params: {
assert(jsCode !== null);
const { fixedJsCode } = replaceImportsInJsCode({
jsCode,
buildContext
});
const { fixedJsCode } = replaceImportsFromStaticInJsCode({ jsCode });
$(element).text(fixedJsCode);
});
@ -65,10 +50,9 @@ export function generateFtlFilesCodeFactory(params: {
assert(cssCode !== null);
const { fixedCssCode } = replaceImportsInCssCode({
const { fixedCssCode } = replaceImportsInInlineCssCode({
cssCode,
cssFileRelativeDirPath: undefined,
buildContext
buildOptions
});
$(element).text(fixedCssCode);
@ -77,8 +61,7 @@ export function generateFtlFilesCodeFactory(params: {
(
[
["link", "href"],
["script", "src"],
["script", "data-src"]
["script", "src"]
] as const
).forEach(([selector, attrName]) =>
$(selector).each((...[, element]) => {
@ -90,47 +73,55 @@ export function generateFtlFilesCodeFactory(params: {
$(element).attr(
attrName,
href.replace(
new RegExp(
`^${(buildContext.urlPathname ?? "/").replace(/\//g, "\\/")}`
),
`\${xKeycloakify.resourcesPath}/${WELL_KNOWN_DIRECTORY_BASE_NAME.DIST}/`
)
href.replace(new RegExp(`^${(buildOptions.urlPathname ?? "/").replace(/\//g, "\\/")}`), "${url.resourcesPath}/build/")
);
})
);
if (Object.keys(cssGlobalsToDefine).length !== 0) {
$("head").prepend(
[
"",
"<style>",
generateCssCodeToDefineGlobals({
cssGlobalsToDefine,
buildOptions
}).cssCodeToPrependInHead,
"</style>",
""
].join("\n")
);
}
}
//FTL is no valid html, we can't insert with cheerio, we put placeholder for injecting later.
const kcContextDeclarationTemplateFtl = fs
.readFileSync(
pathJoin(
getThisCodebaseRootDirPath(),
"src",
"bin",
"keycloakify",
"generateFtl",
"kcContextDeclarationTemplate.ftl"
)
)
.toString("utf8")
.replace("{{themeType}}", themeType)
.replace("{{themeName}}", themeName)
.replace("{{keycloakifyVersion}}", keycloakifyVersion)
.replace("{{themeVersion}}", buildContext.themeVersion)
.replace("{{fieldNames}}", fieldNames.map(name => `"${name}"`).join(", "))
.replace("{{RESOURCES_COMMON}}", WELL_KNOWN_DIRECTORY_BASE_NAME.RESOURCES_COMMON)
.replace("{{KEYCLOAKIFY_SPA_DEV_SERVER_PORT}}", KEYCLOAKIFY_SPA_DEV_SERVER_PORT)
.replace(
"{{userDefinedExclusions}}",
buildContext.kcContextExclusionsFtlCode ?? ""
);
const ftlObjectToJsCodeDeclaringAnObjectPlaceholder =
'{ "x": "vIdLqMeOed9sdLdIdOxdK0d" }';
const replaceValueBySearchValue = {
'{ "x": "vIdLqMeOed9sdLdIdOxdK0d" }': fs
.readFileSync(pathJoin(__dirname, "ftl_object_to_js_code_declaring_an_object.ftl"))
.toString("utf8")
.match(/^<script>const _=((?:.|\n)+)<\/script>[\n]?$/)![1]
.replace("FIELD_NAMES_eKsIY4ZsZ4xeM", fieldNames.map(name => `"${name}"`).join(", "))
.replace("KEYCLOAKIFY_VERSION_xEdKd3xEdr", keycloakifyVersion)
.replace("KEYCLOAKIFY_THEME_VERSION_sIgKd3xEdr3dx", buildOptions.themeVersion)
.replace("KEYCLOAKIFY_THEME_TYPE_dExKd3xEdr", themeType)
.replace("KEYCLOAKIFY_THEME_NAME_cXxKd3xEer", buildOptions.themeName),
"<!-- xIdLqMeOedErIdLsPdNdI9dSlxI -->": [
"<#if scripts??>",
" <#list scripts as script>",
' <script src="${script}" type="text/javascript"></script>',
" </#list>",
"</#if>"
].join("\n")
};
$("head").prepend(
`<script>\n${ftlObjectToJsCodeDeclaringAnObjectPlaceholder}\n</script>`
[
"<script>",
` window.${ftlValuesGlobalName}= ${objectKeys(replaceValueBySearchValue)[0]};`,
"</script>",
"",
objectKeys(replaceValueBySearchValue)[1]
].join("\n")
);
// Remove part of the document marked as ignored.
@ -139,9 +130,7 @@ export function generateFtlFilesCodeFactory(params: {
startTags.each((...[, startTag]) => {
const $startTag = $(startTag);
const $endTag = $startTag
.nextAll('meta[name="keycloakify-ignore-end"]')
.first();
const $endTag = $startTag.nextAll('meta[name="keycloakify-ignore-end"]').first();
if ($endTag.length) {
let currentNode = $startTag.next();
@ -168,14 +157,9 @@ export function generateFtlFilesCodeFactory(params: {
let ftlCode = $.html();
Object.entries({
[ftlObjectToJsCodeDeclaringAnObjectPlaceholder]:
kcContextDeclarationTemplateFtl,
"{{pageId}}": pageId,
"{{ftlTemplateFileName}}": pageId
}).map(
([searchValue, replaceValue]) =>
(ftlCode = ftlCode.replace(searchValue, replaceValue))
);
...replaceValueBySearchValue,
"PAGE_ID_xIgLsPgGId9D8e": pageId
}).map(([searchValue, replaceValue]) => (ftlCode = ftlCode.replace(searchValue, replaceValue)));
return { ftlCode };
}

View File

@ -1 +1,2 @@
export * from "./generateFtl";
export * from "./pageId";

View File

@ -1,724 +0,0 @@
<#assign xKeycloakify={
"messages": {},
"pageId": "{{pageId}}",
"ftlTemplateFileName": "{{ftlTemplateFileName}}",
"themeType": "{{themeType}}",
"themeName": "{{themeName}}",
"keycloakifyVersion": "{{keycloakifyVersion}}",
"themeVersion": "{{themeVersion}}",
"resourcesPath": ""
}>
<#if url?? && url?is_hash && url.resourcesPath?? && url.resourcesPath?is_string>
<#assign xKeycloakify = xKeycloakify + { "resourcesPath": url.resourcesPath }>
</#if>
<#if resourceUrl?? && resourceUrl?is_string>
<#assign xKeycloakify = xKeycloakify + { "resourcesPath": resourceUrl }>
</#if>
const kcContext = ${toJsDeclarationString(.data_model, [])?no_esc};
kcContext.keycloakifyVersion = "${xKeycloakify.keycloakifyVersion}";
kcContext.themeVersion = "${xKeycloakify.themeVersion}";
kcContext.themeType = "${xKeycloakify.themeType}";
kcContext.themeName = "${xKeycloakify.themeName}";
kcContext.pageId = "${xKeycloakify.pageId}";
kcContext.ftlTemplateFileName = "${xKeycloakify.ftlTemplateFileName}";
<@addNonAutomaticallyGatherableMessagesToXKeycloakifyMessages />
kcContext["x-keycloakify"] = {};
kcContext["x-keycloakify"].resourcesPath = "${xKeycloakify.resourcesPath}";
{
var messages = {};
<#list xKeycloakify.messages as key, resolvedMsg>
messages["${key}"] = decodeHtmlEntities("${resolvedMsg?js_string}");
</#list>
kcContext["x-keycloakify"].messages = messages;
}
if(
kcContext.url instanceof Object &&
typeof kcContext.url.resourcesPath === "string"
){
kcContext.url.resourcesCommonPath = kcContext.url.resourcesPath + "/{{RESOURCES_COMMON}}";
}
if( kcContext.messagesPerField ){
var existsError_singleFieldName = kcContext.messagesPerField.existsError;
kcContext.messagesPerField.existsError = function (){
for( let i = 0; i < arguments.length; i++ ){
if( existsError_singleFieldName(arguments[i]) ){
return true;
}
}
return false;
};
kcContext.messagesPerField.exists = function (fieldName) {
return kcContext.messagesPerField.get(fieldName) !== "";
};
kcContext.messagesPerField.printIfExists = function (fieldName, text) {
return kcContext.messagesPerField.exists(fieldName) ? text : undefined;
};
kcContext.messagesPerField.getFirstError = function () {
for( let i = 0; i < arguments.length; i++ ){
const fieldName = arguments[i];
if( kcContext.messagesPerField.existsError(fieldName) ){
return kcContext.messagesPerField.get(fieldName);
}
}
};
}
attributes_to_attributesByName: {
if( !kcContext.profile ){
break attributes_to_attributesByName;
}
if( !kcContext.profile.attributes ){
break attributes_to_attributesByName;
}
var attributes = kcContext.profile.attributes;
delete kcContext.profile.attributes;
kcContext.profile.attributesByName = {};
attributes.forEach(function(attribute){
kcContext.profile.attributesByName[attribute.name] = attribute;
});
}
redirect_to_dev_server: {
switch(kcContext.themeType){
case "login":
break redirect_to_dev_server;
case "account":
if( kcContext.pageId !== "index.ftl" ){
break redirect_to_dev_server;
}
break;
case "admin":
break;
default:
break redirect_to_dev_server;
}
const devSeverPort = kcContext.properties.{{KEYCLOAKIFY_SPA_DEV_SERVER_PORT}};
if( !devSeverPort ){
break redirect_to_dev_server;
}
const redirectUrl = new URL(window.location.href);
redirectUrl.port = devSeverPort;
delete kcContext.msgJSON;
console.log(kcContext);
redirectUrl.searchParams.set("kcContext", encodeURIComponent(JSON.stringify(kcContext)));
window.location.href = redirectUrl.toString();
}
window.kcContext = kcContext;
<#if xKeycloakify.themeType == "login" >
{
const script = document.createElement("script");
script.type = "importmap";
script.textContent = JSON.stringify({
imports: {
"rfc4648": kcContext.url.resourcesCommonPath + "/node_modules/rfc4648/lib/rfc4648.js"
}
}, null, 2);
document.head.appendChild(script);
}
</#if>
function decodeHtmlEntities(htmlStr){
var element = decodeHtmlEntities.element;
if (!element) {
element = document.createElement("textarea");
decodeHtmlEntities.element = element;
}
element.innerHTML = htmlStr;
return element.value;
}
<#function toJsDeclarationString object path>
<#local isHash = -1>
<#attempt>
<#local isHash = object?is_hash || object?is_hash_ex>
<#recover>
<#return "ABORT: Can't evaluate if " + path?join(".") + " is a hash">
</#attempt>
<#if isHash>
<#if path?size gt 10>
<#return "ABORT: Too many recursive calls, path: " + path?join(".")>
</#if>
<#local keys = -1>
<#attempt>
<#local keys = object?keys>
<#recover>
<#return "ABORT: We can't list keys on object">
</#attempt>
<#local outSeq = []>
<#list keys as key>
<#if ["class","declaredConstructors","superclass","declaringClass" ]?seq_contains(key) >
<#continue>
</#if>
<#if (
areSamePath(path, ["url"]) &&
["loginUpdatePasswordUrl", "loginUpdateProfileUrl", "loginUsernameReminderUrl", "loginUpdateTotpUrl"]?seq_contains(key)
) || (
key == "updateProfileCtx" &&
areSamePath(path, [])
) || (
<#-- https://github.com/keycloakify/keycloakify/pull/65#issuecomment-991896344 (reports with saml-post-form.ftl) -->
<#-- https://github.com/keycloakify/keycloakify/issues/91#issue-1212319466 (reports with error.ftl and Kc18) -->
<#-- https://github.com/keycloakify/keycloakify/issues/109#issuecomment-1134610163 -->
<#-- https://github.com/keycloakify/keycloakify/issues/357 -->
<#-- https://github.com/keycloakify/keycloakify/discussions/406#discussioncomment-7514787 -->
key == "loginAction" &&
areSamePath(path, ["url"]) &&
["saml-post-form.ftl", "error.ftl", "info.ftl", "login-oauth-grant.ftl", "logout-confirm.ftl", "login-oauth2-device-verify-user-code.ftl", "frontchannel-logout.ftl"]?seq_contains(xKeycloakify.pageId) &&
!(auth?has_content && auth.showTryAnotherWayLink())
) || (
<#-- https://github.com/keycloakify/keycloakify/issues/362 -->
["secretData", "value"]?seq_contains(key) &&
areSamePath(path, [ "totp", "otpCredentials", "*" ])
) || (
["contextData", "idpConfig", "idp", "authenticationSession"]?seq_contains(key) &&
areSamePath(path, ["brokerContext"]) &&
["login-idp-link-confirm.ftl", "login-idp-link-email.ftl" ]?seq_contains(xKeycloakify.pageId)
) || (
key == "identityProviderBrokerCtx" &&
areSamePath(path, []) &&
["login-idp-link-confirm.ftl", "login-idp-link-email.ftl" ]?seq_contains(xKeycloakify.pageId)
) || (
["masterAdminClient", "delegateForUpdate", "defaultRole", "smtpConfig"]?seq_contains(key) &&
areSamePath(path, ["realm"])
) || (
xKeycloakify.pageId == "error.ftl" &&
areSamePath(path, ["realm"]) &&
!["name", "displayName", "displayNameHtml", "internationalizationEnabled", "registrationEmailAsUsername" ]?seq_contains(key)
) || (
xKeycloakify.pageId == "applications.ftl" &&
(
key == "realm" ||
key == "container"
) &&
isSubpath(path, ["applications", "applications"])
) || (
key == "delegateForUpdate" &&
areSamePath(path, ["user"])
) || (
<#-- Security audit forwarded by Garth (Gmail) -->
key == "saml.signing.private.key" &&
areSamePath(path, ["client", "attributes"])
) || (
<#-- See: https://github.com/keycloakify/keycloakify/issues/534 -->
key == "password" &&
areSamePath(path, ["login"])
) || (
<#-- Remove realmAttributes added by https://github.com/jcputney/keycloak-theme-additional-info-extension for peace of mind. -->
key == "realmAttributes" &&
areSamePath(path, [])
) || (
<#-- attributesByName adds a lot of noise to the output and is not needed, we already have profile.attributes -->
key == "attributesByName" &&
areSamePath(path, ["profile"])
) || (
<#-- We already have the attributes in profile speedup the rendering by filtering it out from the register object -->
(key == "attributes" || key == "attributesByName") &&
areSamePath(path, ["register"])
) || (
areSamePath(path, ["properties"]) &&
(
key?starts_with("kc") ||
key == "locales" ||
key == "import" ||
key == "parent" ||
key == "meta" ||
key == "stylesCommon" ||
key == "styles" ||
key == "accountResourceProvider"
)
) || (
key == "execution" &&
areSamePath(path, [])
) || (
key == "entity" &&
areSamePath(path, ["user"])
) || (
key == "attributes" &&
areSamePath(path, ["realm"])
) || (
xKeycloakify.pageId == "index.ftl" &&
xKeycloakify.themeType == "account" &&
areSamePath(path, ["realm"]) &&
![
"name",
"registrationEmailAsUsername",
"editUsernameAllowed",
"isInternationalizationEnabled",
"identityFederationEnabled",
"userManagedAccessAllowed"
]?seq_contains(key)
) || (
["flowContext", "session", "realm"]?seq_contains(key) &&
areSamePath(path, ["social"])
)
>
<#-- <#local outSeq += ["/*" + path?join(".") + "." + key + " excluded*/"]> -->
<#continue>
</#if>
<#-- https://github.com/keycloakify/keycloakify/discussions/406 -->
<#if (
key == "attemptedUsername" &&
areSamePath(path, ["auth"]) &&
[
"register.ftl", "terms.ftl", "info.ftl", "login.ftl",
"login-update-password.ftl", "login-oauth2-device-verify-user-code.ftl"
]?seq_contains(xKeycloakify.pageId)
)>
<#attempt>
<#-- https://github.com/keycloak/keycloak/blob/3a2bf0c04bcde185e497aaa32d0bb7ab7520cf4a/themes/src/main/resources/theme/base/login/template.ftl#L63 -->
<#if !(auth?has_content && auth.showUsername() && !auth.showResetCredentials())>
<#local outSeq += ["/*" + path?join(".") + "." + key + " excluded*/"]>
<#continue>
</#if>
<#recover>
<#local outSeq += ["/*Accessing attemptedUsername throwed an exception */"]>
</#attempt>
</#if>
{{userDefinedExclusions}}
<#attempt>
<#if !object[key]??>
<#continue>
</#if>
<#recover>
<#local outSeq += ["/*Couldn't test if '" + key + "' is available on this object*/"]>
<#continue>
</#attempt>
<#local propertyValue = -1>
<#attempt>
<#local propertyValue = object[key]>
<#recover>
<#local outSeq += ["/*Couldn't dereference '" + key + "' on this object*/"]>
<#continue>
</#attempt>
<#local recOut = toJsDeclarationString(propertyValue, path + [ key ])>
<#if recOut?starts_with("ABORT:")>
<#local errorMessage = recOut?remove_beginning("ABORT:")>
<#if errorMessage != " It's a method" >
<#local outSeq += ["/*" + key + ": " + errorMessage + "*/"]>
</#if>
<#continue>
</#if>
<#local outSeq += ['"' + key + '": ' + recOut + ","]>
</#list>
<#return (["{"] + outSeq?map(str -> ""?right_pad(4 * (path?size + 1)) + str) + [ ""?right_pad(4 * path?size) + "}"])?join("\n")>
</#if>
<#local isMethod = -1>
<#attempt>
<#local isMethod = object?is_method>
<#recover>
<#return "ABORT: Can't test if it'sa method.">
</#attempt>
<#if isMethod>
<#if areSamePath(path, ["auth", "showUsername"])>
<#attempt>
<#return auth.showUsername()?c>
<#recover>
<#return "ABORT: Couldn't evaluate auth.showUsername()">
</#attempt>
</#if>
<#if areSamePath(path, ["auth", "showResetCredentials"])>
<#attempt>
<#return auth.showResetCredentials()?c>
<#recover>
<#return "ABORT: Couldn't evaluate auth.showResetCredentials()">
</#attempt>
</#if>
<#if areSamePath(path, ["auth", "showTryAnotherWayLink"])>
<#attempt>
<#return auth.showTryAnotherWayLink()?c>
<#recover>
<#return "ABORT: Couldn't evaluate auth.showTryAnotherWayLink()">
</#attempt>
</#if>
<#if areSamePath(path, ["url", "getLogoutUrl"])>
<#local returnValue = -1>
<#attempt>
<#local returnValue = url.getLogoutUrl()>
<#recover>
<#return "ABORT: Couldn't evaluate url.getLogoutUrl()">
</#attempt>
<#return 'function(){ return "' + returnValue + '"; }'>
</#if>
<#if areSamePath(path, ["totp", "policy", "getAlgorithmKey"])>
<#local returnValue = "error">
<#if mode?? && mode = "manual">
<#attempt>
<#local returnValue = totp.policy.getAlgorithmKey()>
<#recover>
<#return "ABORT: Couldn't evaluate totp.policy.getAlgorithmKey()">
</#attempt>
</#if>
<#return 'function(){ return "' + returnValue + '"; }'>
</#if>
<#assign fieldNames = [{{fieldNames}}]>
<#if profile?? && profile.attributes??>
<#list profile.attributes as attribute>
<#if fieldNames?seq_contains(attribute.name)>
<#continue>
</#if>
<#assign fieldNames += [attribute.name]>
</#list>
</#if>
<#if areSamePath(path, ["messagesPerField", "get"])>
<#local jsFunctionCode = "function (fieldName) { ">
<#list fieldNames as fieldName>
<#-- See: https://github.com/keycloakify/keycloakify/issues/217 -->
<#if xKeycloakify.pageId == "login.ftl" >
<#if fieldName == "username">
<#local jsFunctionCode += "if(fieldName === 'username' || fieldName === 'password' ){ ">
<#if messagesPerField.exists('username') || messagesPerField.exists('password')>
<#local jsFunctionCode += "return kcContext.message && kcContext.message.summary ? kcContext.message.summary : 'error'; ">
<#else>
<#local jsFunctionCode += "return ''; ">
</#if>
<#local jsFunctionCode += "} ">
<#continue>
</#if>
<#if fieldName == "password">
<#continue>
</#if>
</#if>
<#local jsFunctionCode += "if(fieldName === '" + fieldName + "'){ ">
<#if messagesPerField.exists('${fieldName}')>
<#local jsFunctionCode += 'return decodeHtmlEntities("' + messagesPerField.get('${fieldName}')?js_string + '"); '>
<#else>
<#local jsFunctionCode += "return ''; ">
</#if>
<#local jsFunctionCode += "} ">
</#list>
<#local jsFunctionCode += "}">
<#return jsFunctionCode>
</#if>
<#if areSamePath(path, ["messagesPerField", "existsError"])>
<#local jsFunctionCode = "function (fieldName) { ">
<#list fieldNames as fieldName>
<#-- See: https://github.com/keycloakify/keycloakify/issues/217 -->
<#if xKeycloakify.pageId == "login.ftl" >
<#if fieldName == "username">
<#local jsFunctionCode += "if(fieldName === 'username' || fieldName === 'password' ){ ">
<#if messagesPerField.existsError('username') || messagesPerField.existsError('password')>
<#local jsFunctionCode += "return true; ">
<#else>
<#local jsFunctionCode += "return false; ">
</#if>
<#local jsFunctionCode += "} ">
<#continue>
</#if>
<#if fieldName == "password">
<#continue>
</#if>
</#if>
<#local jsFunctionCode += "if(fieldName === '" + fieldName + "' ){ ">
<#if messagesPerField.existsError('${fieldName}')>
<#local jsFunctionCode += 'return true; '>
<#else>
<#local jsFunctionCode += "return false; ">
</#if>
<#local jsFunctionCode += "}">
</#list>
<#local jsFunctionCode += "}">
<#return jsFunctionCode>
</#if>
<#if xKeycloakify.themeType == "account" && areSamePath(path, ["realm", "isInternationalizationEnabled"])>
<#attempt>
<#return realm.isInternationalizationEnabled()?c>
<#recover>
<#return "ABORT: Couldn't evaluate realm.isInternationalizationEnabled()">
</#attempt>
</#if>
<#return "ABORT: It's a method">
</#if>
<#local isBoolean = -1>
<#attempt>
<#local isBoolean = object?is_boolean>
<#recover>
<#return "ABORT: Can't test if it's a boolean">
</#attempt>
<#if isBoolean>
<#return object?c>
</#if>
<#local isEnumerable = -1>
<#attempt>
<#local isEnumerable = object?is_enumerable>
<#recover>
<#return "ABORT: Can't test if it's an enumerable">
</#attempt>
<#if isEnumerable>
<#local outSeq = []>
<#local i = 0>
<#list object as array_item>
<#if !array_item??>
<#local outSeq += ["null,"]>
<#continue>
</#if>
<#local recOut = toJsDeclarationString(array_item, path + [ i ])>
<#local i = i + 1>
<#if recOut?starts_with("ABORT:")>
<#local errorMessage = recOut?remove_beginning("ABORT:")>
<#if errorMessage != " It's a method" >
<#local outSeq += ["/*" + i?string + ": " + errorMessage + "*/"]>
</#if>
<#continue>
</#if>
<#local outSeq += [recOut + ","]>
</#list>
<#return (["["] + outSeq?map(str -> ""?right_pad(4 * (path?size + 1)) + str) + [ ""?right_pad(4 * path?size) + "]"])?join("\n")>
</#if>
<#local isDate = -1>
<#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>
<#local isNumber = -1>
<#attempt>
<#local isNumber = object?is_number>
<#recover>
<#return "ABORT: Can't test if it's a number">
</#attempt>
<#if isNumber>
<#return object?c>
</#if>
<#local isString = -1>
<#attempt>
<#local isString = object?is_string>
<#recover>
<#return "ABORT: Can't test if it's a string">
</#attempt>
<#if isString>
<@addToXKeycloakifyMessagesIfMessageKey str=object />
</#if>
<#attempt>
<#return '"' + object?js_string + '"'>;
<#recover>
</#attempt>
<#return "ABORT: Couldn't convert into string non hash, non method, non boolean, non number, non enumerable object">
</#function>
<#function isSubpath path searchedPath>
<#if path?size < searchedPath?size>
<#return false>
</#if>
<#local i=0>
<#list path as property>
<#if i == searchedPath?size >
<#continue>
</#if>
<#local searchedProperty=searchedPath[i]>
<#local i+= 1>
<#if searchedProperty?is_string && searchedProperty == "*">
<#continue>
</#if>
<#if searchedProperty?is_string && !property?is_string>
<#return false>
</#if>
<#if searchedProperty?is_number && !property?is_number>
<#return false>
</#if>
<#if searchedProperty?string != property?string>
<#return false>
</#if>
</#list>
<#return true>
</#function>
<#function areSamePath path searchedPath>
<#return path?size == searchedPath?size && isSubpath(path, searchedPath)>
</#function>
<#macro addToXKeycloakifyMessagesIfMessageKey str>
<#if !msg?? || !msg?is_method>
<#return>
</#if>
<#if (str?length > 200)>
<#return>
</#if>
<#local key=removeBrackets(str)>
<#if key?length==0>
<#return>
</#if>
<#if !(key?matches(r"^[a-zA-Z0-9-_.]*$"))>
<#return>
</#if>
<#local resolvedMsg=msg(key)>
<#if resolvedMsg==key>
<#return>
</#if>
<#local messages=xKeycloakify.messages>
<#local messages = messages + { key: resolvedMsg }>
<#assign xKeycloakify = xKeycloakify + { "messages": messages }>
</#macro>
<#function removeBrackets str>
<#if str?starts_with("${") && str?ends_with("}")>
<#return str[2..(str?length-2)]>
<#else>
<#return str>
</#if>
</#function>
<#macro addNonAutomaticallyGatherableMessagesToXKeycloakifyMessages>
<#if profile?? && profile?is_hash && profile.attributes?? && profile.attributes?is_enumerable>
<#list profile.attributes as attribute>
<#if !(
attribute.annotations?? && attribute.annotations?is_hash &&
attribute.annotations.inputOptionLabelsI18nPrefix?? && attribute.annotations.inputOptionLabelsI18nPrefix?is_string
)>
<#continue>
</#if>
<#local prefix=attribute.annotations.inputOptionLabelsI18nPrefix>
<#if !(
attribute.validators?? && attribute.validators?is_hash &&
attribute.validators.options?? && attribute.validators.options?is_hash &&
attribute.validators.options.options?? && attribute.validators.options.options?is_enumerable
)>
<#continue>
</#if>
<#list attribute.validators.options.options as option>
<#if !option?is_string>
<#continue>
</#if>
<@addToXKeycloakifyMessagesIfMessageKey str="${prefix}.${option}" />
</#list>
</#list>
</#if>
<#if xKeycloakify.pageId == "terms.ftl" || termsAcceptanceRequired?? && termsAcceptanceRequired>
<@addToXKeycloakifyMessagesIfMessageKey str="termsText" />
</#if>
<#if requiredActions?? && requiredActions?is_enumerable>
<#list requiredActions as requiredAction>
<#if !requiredAction?is_string>
<#continue>
</#if>
<@addToXKeycloakifyMessagesIfMessageKey str="requiredAction.${requiredAction}" />
</#list>
</#if>
</#macro>

View File

@ -0,0 +1,33 @@
export const loginThemePageIds = [
"login.ftl",
"login-username.ftl",
"login-password.ftl",
"webauthn-authenticate.ftl",
"register.ftl",
"register-user-profile.ftl",
"info.ftl",
"error.ftl",
"login-reset-password.ftl",
"login-verify-email.ftl",
"terms.ftl",
"login-oauth2-device-verify-user-code.ftl",
"login-oauth-grant.ftl",
"login-otp.ftl",
"login-update-profile.ftl",
"login-update-password.ftl",
"login-idp-link-confirm.ftl",
"login-idp-link-email.ftl",
"login-page-expired.ftl",
"login-config-totp.ftl",
"logout-confirm.ftl",
"update-user-profile.ftl",
"idp-review-user-profile.ftl",
"update-email.ftl",
"select-authenticator.ftl",
"saml-post-form.ftl"
] as const;
export const accountThemePageIds = ["password.ftl", "account.ftl"] as const;
export type LoginThemePageId = (typeof loginThemePageIds)[number];
export type AccountThemePageId = (typeof accountThemePageIds)[number];

View File

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

View File

@ -1,442 +0,0 @@
import { type ThemeType, FALLBACK_LANGUAGE_TAG } from "../../shared/constants";
import { crawl } from "../../tools/crawl";
import { join as pathJoin, dirname as pathDirname } from "path";
import { symToStr } from "tsafe/symToStr";
import * as recast from "recast";
import * as babelParser from "@babel/parser";
import babelGenerate from "@babel/generator";
import * as babelTypes from "@babel/types";
import { escapeStringForPropertiesFile } from "../../tools/escapeStringForPropertiesFile";
import { getThisCodebaseRootDirPath } from "../../tools/getThisCodebaseRootDirPath";
import * as fs from "fs";
import { assert } from "tsafe/assert";
import type { BuildContext } from "../../shared/buildContext";
import { getAbsoluteAndInOsFormatPath } from "../../tools/getAbsoluteAndInOsFormatPath";
export type BuildContextLike = {
themeNames: string[];
themeSrcDirPath: string;
};
assert<BuildContext extends BuildContextLike ? true : false>();
export function generateMessageProperties(params: {
buildContext: BuildContextLike;
themeType: Exclude<ThemeType, "admin">;
}): {
languageTags: string[];
writeMessagePropertiesFiles: (params: {
messageDirPath: string;
themeName: string;
}) => void;
} {
const { buildContext, themeType } = params;
const baseMessagesDirPath = pathJoin(
getThisCodebaseRootDirPath(),
"src",
themeType,
"i18n",
"messages_defaultSet"
);
const messages_defaultSet_by_languageTag_defaultSet: {
[languageTag_defaultSet: string]: Record<string, string>;
} = Object.fromEntries(
fs
.readdirSync(baseMessagesDirPath)
.filter(basename => basename !== "index.ts" && basename !== "types.ts")
.map(basename => ({
languageTag: basename.replace(/\.ts$/, ""),
filePath: pathJoin(baseMessagesDirPath, basename)
}))
.map(({ languageTag, filePath }) => {
const lines = fs.readFileSync(filePath).toString("utf8").split(/\r?\n/);
let messagesJson = "{";
let isInDeclaration = false;
for (const line of lines) {
if (!isInDeclaration) {
if (line.startsWith("const messages")) {
isInDeclaration = true;
}
continue;
}
if (line.startsWith("}")) {
messagesJson += "}";
break;
}
messagesJson += line;
}
const messages = JSON.parse(messagesJson) as Record<string, string>;
return [languageTag, messages];
})
);
const { i18nTsFilePath } = (() => {
let files = crawl({
dirPath: pathJoin(buildContext.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 =>
fs.readFileSync(file).toString("utf8").includes("i18nBuilder")
);
const i18nTsFilePath: string | undefined = files[0];
return { i18nTsFilePath };
})();
const i18nTsRoot = (() => {
if (i18nTsFilePath === undefined) {
return undefined;
}
const root = recastParseTs(i18nTsFilePath);
return root;
})();
const messages_defaultSet_by_languageTag_notInDefaultSet:
| { [languageTag_notInDefaultSet: string]: Record<string, string> }
| undefined = (() => {
if (i18nTsRoot === undefined) {
return undefined;
}
let extraLanguageEntryByLanguageTag: Record<
string,
{ label: string; path: string }
> = {};
recast.visit(i18nTsRoot, {
visitCallExpression: function (path) {
const node = path.node;
// Check if the callee is a MemberExpression with property 'withExtraLanguages'
if (
node.callee.type === "MemberExpression" &&
node.callee.property.type === "Identifier" &&
node.callee.property.name === "withExtraLanguages"
) {
const arg = node.arguments[0];
if (arg && arg.type === "ObjectExpression") {
// Iterate over the properties of the object
arg.properties.forEach(prop => {
if (
prop.type === "ObjectProperty" &&
prop.key.type === "Identifier"
) {
const lang = prop.key.name;
const value = prop.value;
if (value.type === "ObjectExpression") {
let label: string | undefined = undefined;
let pathStr: string | undefined = undefined;
// Iterate over the properties of the language object
value.properties.forEach(p => {
if (
p.type === "ObjectProperty" &&
p.key.type === "Identifier"
) {
if (
p.key.name === "label" &&
p.value.type === "StringLiteral"
) {
label = p.value.value;
}
if (
p.key.name === "getMessages" &&
(p.value.type ===
"ArrowFunctionExpression" ||
p.value.type === "FunctionExpression")
) {
// Extract the import path from the function body
const body = p.value.body;
if (
body.type === "CallExpression" &&
body.callee.type === "Import"
) {
const importArg = body.arguments[0];
if (
importArg.type === "StringLiteral"
) {
pathStr = importArg.value;
}
} else if (
body.type === "BlockStatement"
) {
// If the function body is a block (e.g., function with braces {})
// Look for return statement
body.body.forEach(statement => {
if (
statement.type ===
"ReturnStatement" &&
statement.argument &&
statement.argument.type ===
"CallExpression" &&
statement.argument.callee
.type === "Import"
) {
const importArg =
statement.argument
.arguments[0];
if (
importArg.type ===
"StringLiteral"
) {
pathStr = importArg.value;
}
}
});
}
}
}
});
if (label && pathStr) {
extraLanguageEntryByLanguageTag[lang] = {
label,
path: pathStr
};
}
}
}
});
}
return false; // Stop traversing this path
}
this.traverse(path); // Continue traversing other paths
}
});
const messages_defaultSet_by_languageTag_notInDefaultSet = Object.fromEntries(
Object.entries(extraLanguageEntryByLanguageTag).map(
([languageTag, { path: relativePathWithoutExt }]) => [
languageTag,
(() => {
const filePath = getAbsoluteAndInOsFormatPath({
pathIsh: relativePathWithoutExt.endsWith(".ts")
? relativePathWithoutExt
: `${relativePathWithoutExt}.ts`,
cwd: pathDirname(i18nTsFilePath)
});
const root = recastParseTs(filePath);
let declarationCode: string | undefined = "";
recast.visit(root, {
visitVariableDeclarator: function (path) {
const node = path.node;
// Check if the variable name is 'messages'
if (
node.id.type === "Identifier" &&
node.id.name === "messages"
) {
// Ensure there is an initializer
if (node.init) {
// Generate code from the initializer, preserving comments
declarationCode = recast
.print(node.init)
.code.replace(/}.*$/, "}");
}
return false; // Stop traversing this path
}
this.traverse(path); // Continue traversing other paths
}
});
assert(
declarationCode !== undefined,
`${filePath} does not contain a 'messages' variable declaration`
);
let messages: Record<string, string> = {};
try {
eval(`${symToStr({ messages })} = ${declarationCode};`);
} catch {
throw new Error(
`The declaration of 'message' in ${filePath} cannot be statically evaluated: ${declarationCode}`
);
}
return messages;
})()
]
)
);
return messages_defaultSet_by_languageTag_notInDefaultSet;
})();
const messages_defaultSet_by_languageTag = {
...messages_defaultSet_by_languageTag_defaultSet,
...messages_defaultSet_by_languageTag_notInDefaultSet
};
const messages_themeDefined_by_languageTag:
| {
[languageTag: string]:
| Record<string, string | Record<string, string>>
| undefined;
}
| undefined = (() => {
if (i18nTsRoot === undefined) {
return undefined;
}
let firstArgumentCode: string | undefined = undefined;
recast.visit(i18nTsRoot, {
visitCallExpression: function (path) {
const node = path.node;
if (
node.callee.type === "MemberExpression" &&
node.callee.property.type === "Identifier" &&
node.callee.property.name === "withCustomTranslations"
) {
firstArgumentCode = babelGenerate(node.arguments[0] as any).code;
return false;
}
this.traverse(path);
}
});
if (firstArgumentCode === undefined) {
return undefined;
}
let messages_themeDefined_by_languageTag: {
[languageTag: string]: Record<string, string | Record<string, string>>;
} = {};
try {
eval(
`${symToStr({ messages_themeDefined_by_languageTag })} = ${firstArgumentCode}`
);
} catch {
console.warn(
[
"WARNING: The argument of withCustomTranslations can't be statically evaluated!",
"This needs to be fixed refer to the documentation: https://docs.keycloakify.dev/i18n",
firstArgumentCode
].join(" ")
);
return undefined;
}
return messages_themeDefined_by_languageTag;
})();
const languageTags = Object.keys(messages_defaultSet_by_languageTag);
return {
languageTags,
writeMessagePropertiesFiles: ({ messageDirPath, themeName }) => {
for (const languageTag of languageTags) {
const messages = {
...messages_defaultSet_by_languageTag[languageTag]
};
add_theme_defined_messages: {
if (messages_themeDefined_by_languageTag === undefined) {
break add_theme_defined_messages;
}
let messages_themeDefined =
messages_themeDefined_by_languageTag[languageTag];
if (messages_themeDefined === undefined) {
messages_themeDefined =
messages_themeDefined_by_languageTag[FALLBACK_LANGUAGE_TAG];
}
if (messages_themeDefined === undefined) {
messages_themeDefined =
messages_themeDefined_by_languageTag[
Object.keys(messages_themeDefined_by_languageTag)[0]
];
}
if (messages_themeDefined === undefined) {
break add_theme_defined_messages;
}
for (const [key, messageOrMessageByThemeName] of Object.entries(
messages_themeDefined
)) {
const message = (() => {
if (typeof messageOrMessageByThemeName === "string") {
return messageOrMessageByThemeName;
}
const message = messageOrMessageByThemeName[themeName];
assert(message !== undefined);
return message;
})();
messages[key] = message;
}
}
const propertiesFileSource = [
"",
...Object.entries(messages).map(
([key, value]) => `${key}=${escapeStringForPropertiesFile(value)}`
),
""
].join("\n");
fs.mkdirSync(messageDirPath, { recursive: true });
fs.writeFileSync(
pathJoin(messageDirPath, `messages_${languageTag}.properties`),
Buffer.from(propertiesFileSource, "utf8")
);
}
}
};
}
function recastParseTs(filePath: string): recast.types.ASTNode {
return recast.parse(fs.readFileSync(filePath).toString("utf8"), {
parser: {
parse: (code: string) =>
babelParser.parse(code, {
sourceType: "module",
plugins: ["typescript"]
}),
generator: babelGenerate,
types: babelTypes
}
});
}

View File

@ -1,756 +0,0 @@
import type { BuildContext } from "../../shared/buildContext";
import fs from "fs";
import { rmSync } from "../../tools/fs.rmSync";
import { transformCodebase } from "../../tools/transformCodebase";
import {
join as pathJoin,
relative as pathRelative,
dirname as pathDirname,
basename as pathBasename
} from "path";
import { replaceImportsInJsCode } from "../replacers/replaceImportsInJsCode";
import { replaceImportsInCssCode } from "../replacers/replaceImportsInCssCode";
import {
generateFtlFilesCodeFactory,
type BuildContextLike as BuildContextLike_kcContextExclusionsFtlCode
} from "../generateFtl";
import {
type ThemeType,
LOGIN_THEME_PAGE_IDS,
ACCOUNT_THEME_PAGE_IDS,
WELL_KNOWN_DIRECTORY_BASE_NAME,
THEME_TYPES,
KEYCLOAKIFY_SPA_DEV_SERVER_PORT
} from "../../shared/constants";
import { assert, type Equals } from "tsafe/assert";
import { readFieldNameUsage } from "./readFieldNameUsage";
import { readExtraPagesNames } from "./readExtraPageNames";
import {
generateMessageProperties,
type BuildContextLike as BuildContextLike_generateMessageProperties
} from "./generateMessageProperties";
import { readThisNpmPackageVersion } from "../../tools/readThisNpmPackageVersion";
import { escapeStringForPropertiesFile } from "../../tools/escapeStringForPropertiesFile";
import { getThisCodebaseRootDirPath } from "../../tools/getThisCodebaseRootDirPath";
import propertiesParser from "properties-parser";
import { createObjectThatThrowsIfAccessed } from "../../tools/createObjectThatThrowsIfAccessed";
import { listInstalledModules } from "../../tools/listInstalledModules";
import { isInside } from "../../tools/isInside";
import { id } from "tsafe/id";
export type BuildContextLike = BuildContextLike_kcContextExclusionsFtlCode &
BuildContextLike_generateMessageProperties & {
themeNames: string[];
extraThemeProperties: string[] | undefined;
projectDirPath: string;
projectBuildDirPath: string;
environmentVariables: { name: string; default: string }[];
implementedThemeTypes: BuildContext["implementedThemeTypes"];
themeSrcDirPath: string;
bundler: "vite" | "webpack";
packageJsonFilePath: string;
};
assert<BuildContext extends BuildContextLike ? true : false>();
export async function generateResources(params: {
buildContext: BuildContextLike;
resourcesDirPath: string;
}): Promise<void> {
const start = Date.now();
const { resourcesDirPath, buildContext } = params;
const [themeName] = buildContext.themeNames;
if (fs.existsSync(resourcesDirPath)) {
rmSync(resourcesDirPath, { recursive: true });
}
const getThemeTypeDirPath = (params: {
themeType: ThemeType | "email";
themeName: string;
}) => {
const { themeType, themeName } = params;
return pathJoin(resourcesDirPath, "theme", themeName, themeType);
};
const writeMessagePropertiesFilesByThemeType: Partial<
Record<
ThemeType | "email",
(params: { messageDirPath: string; themeName: string }) => void
>
> = {};
for (const themeType of [...THEME_TYPES, "email"] as const) {
let isNative: boolean;
{
const v = buildContext.implementedThemeTypes[themeType];
if (!v.isImplemented && !v.isImplemented_native) {
continue;
}
isNative = !v.isImplemented && v.isImplemented_native;
}
const getAccountThemeType = () => {
assert(themeType === "account");
assert(buildContext.implementedThemeTypes.account.isImplemented);
return buildContext.implementedThemeTypes.account.type;
};
const isSpa = (() => {
switch (themeType) {
case "login":
return false;
case "account":
return getAccountThemeType() === "Single-Page";
case "admin":
return true;
case "email":
return false;
}
})();
const themeTypeDirPath = getThemeTypeDirPath({ themeName, themeType });
apply_replacers_and_move_to_theme_resources: {
if (isNative) {
break apply_replacers_and_move_to_theme_resources;
}
const destDirPath = pathJoin(
themeTypeDirPath,
"resources",
WELL_KNOWN_DIRECTORY_BASE_NAME.DIST
);
// NOTE: Prevent accumulation of files in the assets dir, as names are hashed they pile up.
rmSync(destDirPath, { recursive: true, force: true });
if (
themeType !== "login" &&
buildContext.implementedThemeTypes.login.isImplemented
) {
// NOTE: We prevent doing it twice, it has been done for the login theme.
transformCodebase({
srcDirPath: pathJoin(
getThemeTypeDirPath({
themeName,
themeType: "login"
}),
"resources",
WELL_KNOWN_DIRECTORY_BASE_NAME.DIST
),
destDirPath
});
break apply_replacers_and_move_to_theme_resources;
}
{
const dirPath = pathJoin(
buildContext.projectBuildDirPath,
WELL_KNOWN_DIRECTORY_BASE_NAME.KEYCLOAKIFY_DEV_RESOURCES
);
if (fs.existsSync(dirPath)) {
assert(buildContext.bundler === "webpack");
throw new Error(
[
`Keycloakify build error: The ${WELL_KNOWN_DIRECTORY_BASE_NAME.KEYCLOAKIFY_DEV_RESOURCES} directory shouldn't exist in your build directory.`,
`(${pathRelative(process.cwd(), dirPath)}).\n`,
`Theses assets are only required for local development with Storybook.",
"Please remove this directory as an additional step of your command.\n`,
`For example: \`"build": "... && rimraf ${pathRelative(buildContext.projectDirPath, dirPath)}"\``
].join(" ")
);
}
}
transformCodebase({
srcDirPath: buildContext.projectBuildDirPath,
destDirPath,
transformSourceCode: ({ filePath, fileRelativePath, sourceCode }) => {
if (filePath.endsWith(".css")) {
const { fixedCssCode } = replaceImportsInCssCode({
cssCode: sourceCode.toString("utf8"),
cssFileRelativeDirPath: pathDirname(fileRelativePath),
buildContext
});
return {
modifiedSourceCode: Buffer.from(fixedCssCode, "utf8")
};
}
if (filePath.endsWith(".js")) {
const { fixedJsCode } = replaceImportsInJsCode({
jsCode: sourceCode.toString("utf8"),
buildContext
});
return {
modifiedSourceCode: Buffer.from(fixedJsCode, "utf8")
};
}
return { modifiedSourceCode: sourceCode };
}
});
}
generate_ftl_files: {
if (isNative) {
break generate_ftl_files;
}
assert(themeType !== "email");
const { generateFtlFilesCode } = generateFtlFilesCodeFactory({
themeName,
indexHtmlCode: fs
.readFileSync(
pathJoin(buildContext.projectBuildDirPath, "index.html")
)
.toString("utf8"),
buildContext,
keycloakifyVersion: readThisNpmPackageVersion(),
themeType,
fieldNames: isSpa
? []
: (assert(themeType !== "admin"),
readFieldNameUsage({
themeSrcDirPath: buildContext.themeSrcDirPath,
themeType
}))
});
[
...(() => {
switch (themeType) {
case "login":
return LOGIN_THEME_PAGE_IDS;
case "account":
return getAccountThemeType() === "Single-Page"
? ["index.ftl"]
: ACCOUNT_THEME_PAGE_IDS;
case "admin":
return ["index.ftl"];
}
})(),
...(isSpa
? []
: readExtraPagesNames({
themeType,
themeSrcDirPath: buildContext.themeSrcDirPath
}))
].forEach(pageId => {
const { ftlCode } = generateFtlFilesCode({ pageId });
fs.writeFileSync(
pathJoin(themeTypeDirPath, pageId),
Buffer.from(ftlCode, "utf8")
);
});
}
copy_native_theme: {
if (!isNative) {
break copy_native_theme;
}
const dirPath = pathJoin(buildContext.themeSrcDirPath, themeType);
transformCodebase({
srcDirPath: dirPath,
destDirPath: getThemeTypeDirPath({ themeName, themeType }),
transformSourceCode: ({ fileRelativePath, sourceCode }) => {
if (isInside({ dirPath: "messages", filePath: fileRelativePath })) {
return undefined;
}
return { modifiedSourceCode: sourceCode };
}
});
}
let languageTags: string[] | undefined = undefined;
i18n_multi_page: {
if (isNative) {
break i18n_multi_page;
}
if (isSpa) {
break i18n_multi_page;
}
assert(themeType !== "admin" && themeType !== "email");
const wrap = generateMessageProperties({
buildContext,
themeType
});
languageTags = wrap.languageTags;
const { writeMessagePropertiesFiles } = wrap;
writeMessagePropertiesFilesByThemeType[themeType] =
writeMessagePropertiesFiles;
}
let isLegacyAccountSpa = false;
// NOTE: Eventually remove this block.
i18n_single_page_account_legacy: {
if (!isSpa) {
break i18n_single_page_account_legacy;
}
if (themeType !== "account") {
break i18n_single_page_account_legacy;
}
const [moduleMeta] = await listInstalledModules({
packageJsonFilePath: buildContext.packageJsonFilePath,
filter: ({ moduleName }) =>
moduleName === "@keycloakify/keycloak-account-ui"
});
assert(
moduleMeta !== undefined,
`@keycloakify/keycloak-account-ui is supposed to be installed`
);
{
const [majorStr] = moduleMeta.version.split(".");
if (majorStr.length === 6) {
// NOTE: Now we use the format MMmmpp (Major, minor, patch) for example for
// 26.0.7 it would be 260007.
break i18n_single_page_account_legacy;
} else {
// 25.0.4-rc.5 or later
isLegacyAccountSpa = true;
}
}
const messageDirPath_defaults = pathJoin(moduleMeta.dirPath, "messages");
if (!fs.existsSync(messageDirPath_defaults)) {
throw new Error(
`Please update @keycloakify/keycloak-account-ui to 25.0.4-rc.5 or later.`
);
}
isLegacyAccountSpa = true;
const messagesDirPath_dest = pathJoin(
getThemeTypeDirPath({ themeName, themeType: "account" }),
"messages"
);
transformCodebase({
srcDirPath: messageDirPath_defaults,
destDirPath: messagesDirPath_dest
});
apply_theme_changes: {
const messagesDirPath_theme = pathJoin(
buildContext.themeSrcDirPath,
"account",
"messages"
);
if (!fs.existsSync(messagesDirPath_theme)) {
break apply_theme_changes;
}
fs.readdirSync(messagesDirPath_theme).forEach(basename => {
const filePath_src = pathJoin(messagesDirPath_theme, basename);
const filePath_dest = pathJoin(messagesDirPath_dest, basename);
if (!fs.existsSync(filePath_dest)) {
fs.cpSync(filePath_src, filePath_dest);
}
const messages_src = propertiesParser.parse(
fs.readFileSync(filePath_src).toString("utf8")
);
const messages_dest = propertiesParser.parse(
fs.readFileSync(filePath_dest).toString("utf8")
);
const messages = {
...messages_dest,
...messages_src
};
const editor = propertiesParser.createEditor();
Object.entries(messages).forEach(([key, value]) => {
editor.set(key, value);
});
fs.writeFileSync(
filePath_dest,
Buffer.from(editor.toString(), "utf8")
);
});
}
languageTags = fs
.readdirSync(messagesDirPath_dest)
.map(basename =>
basename.replace(/^messages_/, "").replace(/\.properties$/, "")
);
}
i18n_for_spas_and_native: {
if (!isSpa && !isNative) {
break i18n_for_spas_and_native;
}
if (isLegacyAccountSpa) {
break i18n_for_spas_and_native;
}
const messagesDirPath_theme = pathJoin(
buildContext.themeSrcDirPath,
themeType,
isNative ? "messages" : "i18n"
);
if (!fs.existsSync(messagesDirPath_theme)) {
break i18n_for_spas_and_native;
}
const propertiesByLang: Record<
string,
{
base: Buffer;
override: Buffer | undefined;
overrideByThemeName: Record<string, Buffer>;
}
> = {};
fs.readdirSync(messagesDirPath_theme).forEach(basename => {
type ParsedBasename = { lang: string } & (
| {
isOverride: false;
}
| {
isOverride: true;
themeName: string | undefined;
}
);
const parsedBasename = ((): ParsedBasename | undefined => {
const match = basename.match(/^messages_([^.]+)\.properties$/);
if (match === null) {
return undefined;
}
const discriminator = match[1];
const split = discriminator.split("_override");
if (split.length === 1) {
return {
lang: discriminator,
isOverride: false
};
}
assert(split.length === 2);
if (split[1] === "") {
return {
lang: split[0],
isOverride: true,
themeName: undefined
};
}
const match2 = split[1].match(/^_(.+)$/);
assert(match2 !== null);
return {
lang: split[0],
isOverride: true,
themeName: match2[1]
};
})();
if (parsedBasename === undefined) {
return;
}
propertiesByLang[parsedBasename.lang] ??= {
base: createObjectThatThrowsIfAccessed<Buffer>({
debugMessage: `No base ${parsedBasename.lang} translation for ${themeType} theme`
}),
override: undefined,
overrideByThemeName: {}
};
const buffer = fs.readFileSync(pathJoin(messagesDirPath_theme, basename));
if (parsedBasename.isOverride === false) {
propertiesByLang[parsedBasename.lang].base = buffer;
return;
}
if (parsedBasename.themeName === undefined) {
propertiesByLang[parsedBasename.lang].override = buffer;
return;
}
propertiesByLang[parsedBasename.lang].overrideByThemeName[
parsedBasename.themeName
] = buffer;
});
languageTags = Object.keys(propertiesByLang);
writeMessagePropertiesFilesByThemeType[themeType] = ({
messageDirPath,
themeName
}) => {
if (!fs.existsSync(messageDirPath)) {
fs.mkdirSync(messageDirPath, { recursive: true });
}
Object.entries(propertiesByLang).forEach(
([lang, { base, override, overrideByThemeName }]) => {
const messages = propertiesParser.parse(base.toString("utf8"));
if (override !== undefined) {
const overrideMessages = propertiesParser.parse(
override.toString("utf8")
);
Object.entries(overrideMessages).forEach(
([key, value]) => (messages[key] = value)
);
}
if (themeName in overrideByThemeName) {
const overrideMessages = propertiesParser.parse(
overrideByThemeName[themeName].toString("utf8")
);
Object.entries(overrideMessages).forEach(
([key, value]) => (messages[key] = value)
);
}
const editor = propertiesParser.createEditor();
Object.entries(messages).forEach(([key, value]) => {
editor.set(key, value);
});
fs.writeFileSync(
pathJoin(messageDirPath, `messages_${lang}.properties`),
Buffer.from(editor.toString(), "utf8")
);
}
);
};
}
keycloak_static_resources: {
if (isNative) {
break keycloak_static_resources;
}
if (isSpa) {
break keycloak_static_resources;
}
transformCodebase({
srcDirPath: pathJoin(
getThisCodebaseRootDirPath(),
"res",
"public",
WELL_KNOWN_DIRECTORY_BASE_NAME.KEYCLOAKIFY_DEV_RESOURCES,
themeType
),
destDirPath: pathJoin(themeTypeDirPath, "resources")
});
}
bring_in_account_v1: {
if (isNative) {
break bring_in_account_v1;
}
if (themeType !== "account") {
break bring_in_account_v1;
}
assert(buildContext.implementedThemeTypes.account.isImplemented);
if (buildContext.implementedThemeTypes.account.type !== "Multi-Page") {
break bring_in_account_v1;
}
transformCodebase({
srcDirPath: pathJoin(getThisCodebaseRootDirPath(), "res", "account-v1"),
destDirPath: getThemeTypeDirPath({
themeName: "account-v1",
themeType: "account"
})
});
}
generate_theme_properties: {
if (isNative) {
break generate_theme_properties;
}
assert(themeType !== "email");
fs.writeFileSync(
pathJoin(themeTypeDirPath, "theme.properties"),
Buffer.from(
[
`parent=${(() => {
switch (themeType) {
case "account":
switch (getAccountThemeType()) {
case "Multi-Page":
return "account-v1";
case "Single-Page":
return "base";
}
case "login":
return "keycloak";
case "admin":
return "base";
}
assert<Equals<typeof themeType, never>>;
})()}`,
...(themeType === "account" &&
getAccountThemeType() === "Single-Page"
? ["deprecatedMode=false"]
: []),
...(buildContext.extraThemeProperties ?? []),
...[
...buildContext.environmentVariables,
{ name: KEYCLOAKIFY_SPA_DEV_SERVER_PORT, default: "" }
].map(
({ name, default: defaultValue }) =>
`${name}=\${env.${name}:${escapeStringForPropertiesFile(defaultValue)}}`
),
...(languageTags === undefined
? []
: [`locales=${languageTags.join(",")}`])
].join("\n\n"),
"utf8"
)
);
}
}
for (const themeVariantName of [...buildContext.themeNames].reverse()) {
for (const themeType of [...THEME_TYPES, "email"] as const) {
copy_main_theme_to_theme_variant_theme: {
let isNative: boolean;
{
const v = buildContext.implementedThemeTypes[themeType];
if (!v.isImplemented && !v.isImplemented_native) {
break copy_main_theme_to_theme_variant_theme;
}
isNative = !v.isImplemented && v.isImplemented_native;
}
if (!isNative && themeVariantName === themeName) {
break copy_main_theme_to_theme_variant_theme;
}
transformCodebase({
srcDirPath: getThemeTypeDirPath({ themeName, themeType }),
destDirPath: getThemeTypeDirPath({
themeName: themeVariantName,
themeType
}),
transformSourceCode: ({ fileRelativePath, sourceCode }) => {
patch_xKeycloakify_themeName: {
if (!fileRelativePath.endsWith(".ftl")) {
break patch_xKeycloakify_themeName;
}
if (
!isNative &&
pathBasename(fileRelativePath) !== fileRelativePath
) {
break patch_xKeycloakify_themeName;
}
const modifiedSourceCode = Buffer.from(
Buffer.from(sourceCode)
.toString("utf-8")
.replace(
...id<[string | RegExp, string]>(
isNative
? [
/xKeycloakify\.themeName/g,
`"${themeVariantName}"`
]
: [
`"themeName": "${themeName}"`,
`"themeName": "${themeVariantName}"`
]
)
),
"utf8"
);
if (!isNative) {
assert(
Buffer.compare(modifiedSourceCode, sourceCode) !== 0
);
}
return { modifiedSourceCode };
}
return { modifiedSourceCode: sourceCode };
}
});
}
run_writeMessagePropertiesFiles: {
const writeMessagePropertiesFiles =
writeMessagePropertiesFilesByThemeType[themeType];
if (writeMessagePropertiesFiles === undefined) {
break run_writeMessagePropertiesFiles;
}
writeMessagePropertiesFiles({
messageDirPath: pathJoin(
getThemeTypeDirPath({ themeName: themeVariantName, themeType }),
"messages"
),
themeName: themeVariantName
});
}
}
}
console.log(`Generated resources in ${Date.now() - start}ms`);
}

View File

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

View File

@ -1,91 +0,0 @@
import { crawl } from "../../tools/crawl";
import { join as pathJoin } from "path";
import * as fs from "fs";
import type { ThemeType } from "../../shared/constants";
import { getThisCodebaseRootDirPath } from "../../tools/getThisCodebaseRootDirPath";
/** Assumes the theme type exists */
export function readFieldNameUsage(params: {
themeSrcDirPath: string;
themeType: Exclude<ThemeType, "admin">;
}): string[] {
const { themeSrcDirPath, themeType } = params;
// NOTE: We pre-populate with the synthetic user attributes defined in useUserProfileForm (can't be parsed automatically)
const fieldNames = new Set<string>([
"firstName",
"lastName",
"email",
"username",
"password",
"password-confirm"
]);
for (const srcDirPath of [
pathJoin(getThisCodebaseRootDirPath(), "src", 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;
}
for (const functionName of [
"printIfExists",
"existsError",
"get",
"exists",
"getFirstError"
] as const) {
if (!rawSourceFile.includes(functionName)) {
continue;
}
try {
rawSourceFile
.split(functionName)
.filter(part => part.startsWith("("))
.map(part => {
let [p1] = part.split(")");
p1 = p1.slice(1);
return p1;
})
.map(part => {
return part
.split(",")
.map(a => a.trim())
.filter((...[, i]) =>
functionName !== "printIfExists" ? true : i === 0
)
.filter(
a =>
a.startsWith('"') ||
a.startsWith("'") ||
a.startsWith("`")
)
.filter(
a =>
a.endsWith('"') ||
a.endsWith("'") ||
a.endsWith("`")
)
.map(a => a.slice(1).slice(0, -1));
})
.flat()
.forEach(fieldName => fieldNames.add(fieldName));
} catch {}
}
}
}
return Array.from(fieldNames);
}

View File

@ -0,0 +1,59 @@
import * as fs from "fs";
import { join as pathJoin } from "path";
import { assert } from "tsafe/assert";
import type { BuildOptions } from "./BuildOptions";
export type BuildOptionsLike = {
themeName: string;
extraThemeNames: string[];
};
assert<BuildOptions extends BuildOptionsLike ? true : false>();
generateStartKeycloakTestingContainer.basename = "start_keycloak_testing_container.sh";
const containerName = "keycloak-testing-container";
/** Files for being able to run a hot reload keycloak container */
export function generateStartKeycloakTestingContainer(params: {
keycloakVersion: string;
keycloakThemeBuildingDirPath: string;
buildOptions: BuildOptionsLike;
}) {
const {
keycloakThemeBuildingDirPath,
keycloakVersion,
buildOptions: { themeName, extraThemeNames }
} = params;
fs.writeFileSync(
pathJoin(keycloakThemeBuildingDirPath, generateStartKeycloakTestingContainer.basename),
Buffer.from(
[
"#!/usr/bin/env bash",
"",
`docker rm ${containerName} || true`,
"",
`cd "${keycloakThemeBuildingDirPath.replace(/\\/g, "/")}"`,
"",
"docker run \\",
" -p 8080:8080 \\",
` --name ${containerName} \\`,
" -e KEYCLOAK_ADMIN=admin \\",
" -e KEYCLOAK_ADMIN_PASSWORD=admin \\",
...[themeName, ...extraThemeNames].map(
themeName =>
` -v "${pathJoin(keycloakThemeBuildingDirPath, "src", "main", "resources", "theme", themeName).replace(
/\\/g,
"/"
)}":"/opt/keycloak/themes/${themeName}":rw \\`
),
` -it quay.io/keycloak/keycloak:${keycloakVersion} \\`,
` start-dev --features=declarative-user-profile`,
""
].join("\n"),
"utf8"
),
{ "mode": 0o755 }
);
}

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