Compare commits

..

162 Commits

Author SHA1 Message Date
12534e57ad Bump version 2024-10-06 22:09:21 +02:00
52e33bba2d Fix: check for delegation of the eject-page command 2024-10-06 22:08:43 +02:00
d63e5f4e54 Fix litle inconsistency 2024-10-06 15:37:32 +02:00
8d31866a0b Bump version 2024-10-06 15:09:53 +02:00
7d818f217a Merge pull request #683 from keycloakify/feat_custom_handler
Feat custom handler
2024-10-06 15:09:26 +02:00
7156665684 Release candidate 2024-10-06 13:19:12 +02:00
5045c5e8bf Add initialize-email-theme, initialize-account-theme and copy-keycloak-resources-to-public to commands that can be delegated to a custom handler 2024-10-06 13:18:30 +02:00
9de2ed9eaf Fix dead code 2024-10-06 12:44:46 +02:00
096cf7a570 Release candidate 2024-10-06 09:07:10 +02:00
a04f07d149 Make sure the update-kc-gen command is delegated when building with vite 2024-10-06 09:06:49 +02:00
63775b2866 Release candidate 2024-10-06 06:45:06 +02:00
e8609de7b4 No need to handle non react environement with custom handler support 2024-10-06 06:44:53 +02:00
e62aa89d72 Release candidate 2024-10-06 06:42:04 +02:00
77f12a940d Fix not handling correctly exit cause 2024-10-06 06:41:51 +02:00
0fe49e3d6e Release candidate 2024-10-05 22:29:13 +02:00
881386a123 add debug logs 2024-10-05 22:28:36 +02:00
7b9aec4ed0 Release candidate 2024-10-05 21:39:32 +02:00
cf18f9d06c Add other missing declaration files 2024-10-05 21:39:14 +02:00
052936f769 Release candidate 2024-10-05 21:23:57 +02:00
590de7a67b Fix missing exports 2024-10-05 21:23:17 +02:00
7f608ad8ad Release candidate 2024-10-05 20:31:41 +02:00
35b012b937 Implement custom handler cli hook 2024-10-05 20:30:09 +02:00
e3bd7f3bc5 Bump version 2024-10-04 16:56:17 +02:00
e14f187fc0 Fix cache issue 2024-10-04 16:56:02 +02:00
da495b90ae Bump version 2024-10-04 13:00:15 +02:00
8d9b80f549 Update readme, support keycloak 26 2024-10-04 12:59:56 +02:00
2e9da33622 Merge pull request #681 from keycloakify/keycloak-26
Update version target range
2024-10-04 12:58:50 +02:00
6f416ad335 Update version ranges for Multi-Page account theme 2024-10-04 12:58:31 +02:00
4e982ee898 Release candidate 2024-10-04 12:44:22 +02:00
bcb514ae9c Aditional context exclusion 2024-10-04 12:44:03 +02:00
cfdad8d71d Release candidate 2024-10-04 12:17:54 +02:00
39ad1eb8d1 Update version target range 2024-10-04 12:17:08 +02:00
3d1d2e316b Merge pull request #680 from pnzrr/pnzrr-patch-1
Fix link in CONTRIBUTING.md
2024-10-04 06:58:52 +02:00
dd217e8a46 Fix link in CONTRIBUTING.md 2024-10-03 21:04:02 -06:00
1339a96ea4 Bump version 2024-10-02 23:36:58 +02:00
616e834c90 Merge pull request #678 from johanjk/main
respect inputOptionLabels
2024-10-02 23:36:23 +02:00
80eaa77acc ['select-radiobuttons'/'multiselect-checkboxes'] fixed 'inputOptionLabels' 2024-10-02 16:16:16 +02:00
ce3135c83b Bump version 2024-10-02 13:44:22 +02:00
09abc73068 Update tsafe 2024-10-02 13:42:38 +02:00
037d623550 Merge pull request #676 from keycloakify/all-contributors/add-luca-peruzzo
docs: add luca-peruzzo as a contributor for code, and test
2024-10-02 11:05:58 +02:00
8c8d2fd6a8 docs: update .all-contributorsrc [skip ci] 2024-10-02 09:05:35 +00:00
153a99d63f docs: update README.md [skip ci] 2024-10-02 09:05:34 +00:00
939e3ca7ea Put Kathi as first contributor 2024-10-02 11:02:25 +02:00
a0dc7eeb7c Merge pull request #675 from keycloakify/all-contributors/add-kathari00
docs: add kathari00 as a contributor for code, test, and doc
2024-10-02 11:00:06 +02:00
c21d072231 docs: update .all-contributorsrc [skip ci] 2024-10-02 08:59:49 +00:00
2e10ec8073 docs: update README.md [skip ci] 2024-10-02 08:59:48 +00:00
1177d6770c Bump version 2024-10-01 11:59:39 +02:00
d492a393fe Merge pull request #674 from keycloakify/dont_touch_base_url
Avoid modifying BASE_URL for App context
2024-10-01 11:59:14 +02:00
77952337c5 Avoid modifying BASE_URL for App context 2024-10-01 11:52:40 +02:00
6716fcb881 Bump version 2024-09-30 18:10:26 +02:00
302fe8d7cd Update tsafe (provide ESM distribution) 2024-09-30 18:10:09 +02:00
2ea5e34e81 update ci 2024-09-30 17:57:41 +02:00
d7103b1ad9 Bump version 2024-09-30 11:49:33 +02:00
9f8a36fe93 Fix allegated vulnerability 2024-09-30 11:48:57 +02:00
47ca811878 Bump version 2024-09-30 01:22:49 +02:00
8cacb21f1b Remove unessesary reference to react specific construct in KcContext 2024-09-30 01:22:37 +02:00
a0c95207cf Bump version 2024-09-30 01:10:45 +02:00
da3023cf5e Refactor: Make ClassKey importable without having react as a dependency 2024-09-30 00:31:27 +02:00
94779c3476 Bump version 2024-09-28 00:43:55 +02:00
802a6ab5ec Explicitely prohibit space and special character in theme names 2024-09-28 00:34:24 +02:00
04307c8226 Remove dead code 2024-09-28 00:17:17 +02:00
ff6b91b801 refactor 2024-09-28 00:05:19 +02:00
c8ca598465 Refactor 2024-09-27 23:45:14 +02:00
9444b897ee #669 2024-09-27 23:37:23 +02:00
3d1951b72c Merge together generateResourcesForMainTheme and generateResourcesForThemeVariant 2024-09-27 23:05:51 +02:00
acc27ae448 #668 2024-09-26 20:34:00 +02:00
e6993214ff Bump version 2024-09-25 10:26:46 +02:00
2f02a4379c Enable i18n in Single-Page account theme 2024-09-25 10:26:25 +02:00
b57d014e9a Bump version 2024-09-24 19:47:01 +02:00
f57f311aab Fix async io not awaited and don't crash if .ftl files does not exist for some reason 2024-09-24 19:47:01 +02:00
4f11415107 Merge pull request #667 from keycloakify/all-contributors/add-uchar
docs: add uchar as a contributor for test, and code
2024-09-23 05:00:23 +02:00
346fd7175f Only publish storybook if we are on main 2024-09-23 04:36:00 +02:00
7c02d77057 docs: update .all-contributorsrc [skip ci] 2024-09-23 02:31:26 +00:00
d3fd4b6bbf docs: update README.md [skip ci] 2024-09-23 02:31:25 +00:00
43ef527810 Bump version 2024-09-23 00:29:17 +02:00
a6032a1387 Merge pull request #653 from keycloakify/i18n_extraLanguages_and_perThemeVariantTranslations
Start implementing per theme variant translations and ability to add extra languages
2024-09-23 00:23:55 +02:00
23179cac53 Fix last bug 2024-09-23 00:19:34 +02:00
954c3319bb Fix bug in label resolution 2024-09-22 23:46:45 +02:00
eb6ec0275d Remove debug log 2024-09-22 22:53:31 +02:00
890f8bc2d5 Fix: Forget to create a dir before writing files 2024-09-22 22:53:13 +02:00
26b8dd9cda Improve intentionality 2024-09-22 22:48:31 +02:00
c07af8491c Complete statical parsing of withExtraLanguages 2024-09-22 22:46:56 +02:00
10d4da9fbf No need to escape since we sanitize 2024-09-22 22:18:24 +02:00
95e861099f Integrate kcSanitize 2024-09-22 20:41:18 +02:00
6dc51dfab3 Fix some bugs in the vendoring script 2024-09-22 20:21:07 +02:00
ddb0af1dcb Vendor dompurify, use isomorphic-dompurify only for tests 2024-09-22 20:12:11 +02:00
b6e9043d91 Reorganize kcSanitarize 2024-09-22 18:56:05 +02:00
7c553ee10d Restore package.json and yarn.lock 2024-09-22 18:29:29 +02:00
2a6b14adc6 Merge pull request #666 from uchar/fix/dangerouslySetInnerHTML
Fix/dangerously set inner html
2024-09-22 18:27:25 +02:00
159a5f60d0 Add missing scope in ftl template 2024-09-22 18:22:11 +02:00
08f03b3118 Merge branch 'main' into i18n_extraLanguages_and_perThemeVariantTranslations 2024-09-22 18:15:25 +02:00
f137960f96 Reneame useStylesAndScript to useInitialize 2024-09-22 18:12:46 +02:00
e5ab46727a Make the i18n API more type safe 2024-09-22 17:14:03 +02:00
8d2679b76e Progess in parsing of the extra languages provided by the user 2024-09-22 15:39:32 +02:00
b0b6b994ed Almost done, left to extract the extra language resources 2024-09-22 04:39:24 +02:00
bb163132fe Fix minor inconsistency 2024-09-21 23:42:59 +02:00
439bed2f24 Update account theme i18n.ts boilerplate 2024-09-21 23:41:08 +02:00
5a233d8878 Avoid too many types declaration indirections 2024-09-21 23:35:44 +02:00
20cdbb6185 Rename .create() by .build() for i18nBuilder 2024-09-21 23:21:15 +02:00
b3c4208e44 Rename i18nInitializer by i18nBuilder 2024-09-21 23:09:12 +02:00
8623037224 Various little adjustments relative to the new i18n API 2024-09-21 22:35:30 +02:00
e8d3d3d741 Automatically generate account i18n code 2024-09-21 21:44:14 +02:00
cc700f0ba0 Untrack account i18n, code will be generated automatically 2024-09-21 21:33:57 +02:00
801a5cce17 Enable to add label to extra message not in the default set 2024-09-21 21:33:04 +02:00
2a3ad58c18 Throw an error if providing translation for a language that is already supported 2024-09-21 18:17:43 +02:00
969744f4cb Complete runtime API implementation 2024-09-21 17:59:16 +02:00
40ebbdebeb Fix some type errors 2024-09-21 04:45:00 +02:00
eb64886dcf Generate LanguageTage.ts 2024-09-21 04:36:48 +02:00
81fc9d57bd remove async from sanitize 2024-09-18 18:37:17 +03:30
66b480f837 use textarea on client for decode 2024-09-18 11:13:49 +03:30
7e6a84ce19 Add more tests 2024-09-17 09:39:07 +03:30
68e7642827 Remove extra comment 2024-09-17 01:11:14 +03:30
b37c7ccc8a Merge with master 2024-09-17 01:01:45 +03:30
b7c9ba8ffd Merge branch 'main' of https://github.com/uchar/keycloakify into fix/dangerouslySetInnerHTML 2024-09-17 01:01:02 +03:30
c8a31c4b6a Add KCSantisizer 2024-09-17 00:56:46 +03:30
fb6f450bfe Bump version 2024-09-16 14:30:50 +02:00
9a97d86ff9 #638 #631 Follow up 2024-09-16 14:30:27 +02:00
a5e3ecb38b Bump version 2024-09-16 13:50:08 +02:00
9b22d94600 Remove previous .keycloakify dir 2024-09-16 13:49:54 +02:00
a42ddb959b Bump version 2024-09-16 13:38:05 +02:00
b4e94d3c00 Use keycloakify-dev-resources instead of .keycloakify 2024-09-16 13:37:29 +02:00
aad89a2001 Start implementing per theme variant translations and ability to add extra languages 2024-09-15 16:55:18 +02:00
07e4f99f80 Bump version 2024-09-13 12:47:01 +02:00
715562c750 Merge pull request #646 from BII-GmbH/main 2024-09-13 12:46:16 +02:00
ef4f4d8374 allow docker start script to work with podman 2024-09-13 10:58:52 +02:00
e15f13646c GitHub pages does not serve dotfile, patch storybook build #645 2024-09-10 19:31:03 +02:00
8677c17f29 #645 2024-09-10 19:01:20 +02:00
21ee42b5a4 Bump version 2024-09-10 10:41:35 +02:00
d20964ec94 Merge pull request #632 from keycloakify/passkey-conditional-authenticate
Passkey conditional authenticate
2024-09-10 10:40:57 +02:00
3155f5da66 registrationDisabled is optional 2024-09-10 10:12:33 +02:00
50e38b6a10 Complete rework of WebauthnRegister 2024-09-10 09:57:47 +02:00
72c31776d7 Update WebauthnAuthenticate 2024-09-09 08:49:59 +02:00
7456750828 Support the new recapcha without breaking for older keycloak 2024-09-09 08:25:00 +02:00
b8a08f0789 Social is now optional on the kcContext 2024-09-09 07:51:49 +02:00
28990a12da Fix LoginRecoveryAuthnCodeConfig 2024-09-09 07:39:45 +02:00
7e5abe8589 Fix LoginPasskesConditionalAuthenticate 2024-09-09 06:59:11 +02:00
1d57f4b4dc Include missing dependency file 2024-09-08 18:16:49 +02:00
9f875160ea Make template initialization not ejected by default 2024-09-08 17:31:55 +02:00
785ed095bc Repatriate keycloak v24 scripts 2024-09-08 14:41:45 +02:00
359e93a1ba Fix path error 2024-09-08 13:00:40 +02:00
ee6322aae4 Extract only required files 2024-09-08 12:52:29 +02:00
98d3d1967a Fix import error 2024-09-08 12:09:19 +02:00
01c3b148e6 Group all build time generated resource under a 'res' directory 2024-09-08 12:06:49 +02:00
77d3a5190d Refactor checkpoint 2024-09-08 12:00:07 +02:00
93c1c56279 Refactor checkpoint 2024-09-08 00:06:47 +02:00
8340608045 Merge branch 'main' into passkey-conditional-authenticate 2024-09-07 15:27:51 +02:00
5502a74994 Bump version 2024-09-05 01:23:13 +02:00
c5ef4c973b #598 2024-09-05 01:19:50 +02:00
dbae909903 Pulling the message resources of the account theme from the installed keycloak-account-ui version 2024-09-05 00:32:21 +02:00
74317a1f3c Remove extra "v" when initializing single page account theme 2024-09-05 00:31:33 +02:00
569e933f02 Release candidate 2024-09-02 03:37:36 +02:00
46c40d713a Fix broken path 2024-09-02 03:37:16 +02:00
f3602219f3 Release candidate 2024-09-02 03:27:09 +02:00
c6b52acf2f #631 2024-09-02 03:26:39 +02:00
7260589136 Always generates the pages for legacy keycloak no matter what. 2024-08-30 19:27:56 +02:00
b2e9ddaa4f Bump version 2024-08-30 15:35:45 +02:00
4338b3ecb7 #627 2024-08-30 15:35:21 +02:00
0f81d9f146 Update readme 2024-08-29 01:22:16 +02:00
9980b10a83 Bump version 2024-08-28 17:09:21 +02:00
6bfd388827 Fix bug package.json not properly updated when initializing the single page account theme with webpack 2024-08-28 17:08:11 +02:00
8203ed687b Bump version 2024-08-28 14:43:40 +02:00
f394e06e4d Fix bug in type validation for webpack when initializing the account theme 2024-08-28 14:43:25 +02:00
132 changed files with 9135 additions and 3310 deletions

View File

@ -259,6 +259,37 @@
"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"
]
}
],
"contributorsPerLine": 7,

View File

@ -37,7 +37,7 @@ jobs:
storybook:
runs-on: ubuntu-latest
if: github.event_name == 'push'
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs: test
steps:
- uses: actions/checkout@v4
@ -49,7 +49,7 @@ jobs:
- 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 ./storybook-static -u "github-actions-bot <actions@github.com>"
check_if_version_upgraded:
name: Check if version upgrade
@ -112,7 +112,7 @@ jobs:
registry-url: https://registry.npmjs.org/
- uses: bahmutov/npm-install@v1
- run: npm run build
- run: npx -y -p denoify@1.6.12 enable_short_npm_import_path
- run: npx -y -p denoify@1.6.13 enable_short_npm_import_path
env:
DRY_RUN: "0"
- uses: garronej/ts-ci@v2.1.2

2
.gitignore vendored
View File

@ -49,7 +49,7 @@ jspm_packages
.idea
/src/login/i18n/messages_defaultSet/
/src/account/i18n/messages_defaultSet/
/src/account/i18n/
# VS Code devcontainers
.devcontainer

View File

@ -9,5 +9,5 @@ module.exports = {
core: {
builder: "webpack5"
},
staticDirs: ["./static"]
staticDirs: ["./static", "../dist/res/public"]
};

View File

@ -1,49 +0,0 @@
## Overview
This Terms of Service document outlines the rules and regulations for the use of **Example Company's** Services.
## Acceptance of Terms
By accessing and using our services, you acknowledge that you have read, understood, and agree to be bound by these terms. If you do not accept these terms, you are not authorized to use our services.
## Description of Service
**Example Service** (hereinafter referred to as "the Service") is a web-based solution offered by **Example Company** (hereinafter referred to as "the Company"). Our service provides users with access to [documentation](https://example.com/docs) and support for managing their projects effectively.
## Modifications to the Terms of Service
The Company reserves the right to modify these terms at any time. Such modifications will be effective immediately upon posting the updated terms on our website. Your continued use of the Service after any such changes shall constitute your consent to such changes.
## Account Registration
You may be required to register with the Service to access certain features. When registering, you agree to provide accurate, current, and complete information about yourself as requested.
## User Responsibilities
- **Data Security**: Users are responsible for safeguarding their login credentials and should not disclose their passwords to any third party.
- **Acceptable Use**: Users are expected to use the Service in a responsible manner that does not infringe upon the rights of others.
- **Content Ownership**: Users retain all rights to the content they upload to the Service but grant the Company a license to use and distribute this content as part of the Service.
## Intellectual Property
All intellectual property rights related to the Service and its original content, features, and functionality are owned by the Company.
## Termination
The Company may terminate or suspend access to our Service immediately, without prior notice or liability, for any reason whatsoever, including, without limitation, breach of these Terms.
## Governing Law
These Terms shall be governed and construed in accordance with the laws of [Your Country], without regard to its conflict of law provisions.
## Contact Information
For any questions about these Terms, please contact us at [support@example.com](mailto:support@example.com) or visit our [FAQ page](https://example.com/faq).
## Changes to Terms of Service
We reserve the right, at our sole discretion, to modify or replace these Terms at any time. If a revision is material, we will provide at least 30 days' notice prior to any new terms taking effect.
## Effective Date
These terms are effective as of **[Insert Date]**.

View File

@ -1,49 +0,0 @@
## Resumen
Este documento de Términos de Servicio detalla las reglas y regulaciones para el uso de los servicios de **Empresa Ejemplo**.
## Aceptación de Términos
Al acceder y utilizar nuestros servicios, usted reconoce que ha leído, entendido y acepta estar vinculado por estos términos. Si no acepta estos términos, no está autorizado para usar nuestros servicios.
## Descripción del Servicio
**Servicio Ejemplo** (en adelante denominado "el Servicio") es una solución basada en la web ofrecida por **Empresa Ejemplo** (en adelante denominada "la Empresa"). Nuestro servicio proporciona a los usuarios acceso a [documentación](https://ejemplo.com/docs) y soporte para gestionar sus proyectos de manera efectiva.
## Modificaciones a los Términos de Servicio
La Empresa se reserva el derecho de modificar estos términos en cualquier momento. Dichas modificaciones entrarán en vigor inmediatamente después de la publicación de los términos actualizados en nuestro sitio web. Su uso continuado del Servicio después de tales cambios constituirá su consentimiento a dichos cambios.
## Registro de Cuenta
Puede ser necesario que se registre en el Servicio para acceder a ciertas características. Al registrarse, usted acepta proporcionar información precisa, actual y completa sobre sí mismo como se solicita.
## Responsabilidades del Usuario
- **Seguridad de Datos**: Los usuarios son responsables de salvaguardar sus credenciales de inicio de sesión y no deben divulgar sus contraseñas a terceros.
- **Uso Aceptable**: Se espera que los usuarios utilicen el Servicio de manera responsable que no infrinja los derechos de otros.
- **Propiedad del Contenido**: Los usuarios retienen todos los derechos sobre el contenido que cargan en el Servicio, pero otorgan a la Empresa una licencia para usar y distribuir este contenido como parte del Servicio.
## Propiedad Intelectual
Todos los derechos de propiedad intelectual relacionados con el Servicio y su contenido original, características y funcionalidad son propiedad de la Empresa.
## Terminación
La Empresa puede terminar o suspender su acceso a nuestro Servicio de inmediato, sin previo aviso ni responsabilidad, por cualquier motivo, incluido, entre otros, una violación de estos Términos.
## Ley Aplicable
Estos Términos se regirán e interpretarán de acuerdo con las leyes de [Su País], sin tener en cuenta sus disposiciones de conflicto de leyes.
## Información de Contacto
Para cualquier pregunta sobre estos Términos, contáctenos en [support@ejemplo.com](mailto:support@ejemplo.com) o visite nuestra [página de FAQ](https://ejemplo.com/faq).
## Cambios a los Términos de Servicio
Nos reservamos el derecho, a nuestra única discreción, de modificar o reemplazar estos Términos en cualquier momento. Si una revisión es material, proporcionaremos al menos 30 días de aviso antes de que los nuevos términos entren en vigor.
## Fecha de Efectividad
Estos términos son efectivos a partir del **[Insertar Fecha]**.

View File

@ -1,49 +0,0 @@
## Vue d'ensemble
Ce document des Conditions Générales d'Utilisation détaille les règles et réglementations pour l'utilisation des services de **l'Entreprise Exemple**.
## Acceptation des Conditions
En accédant et en utilisant nos services, vous reconnaissez avoir lu, compris et accepté d'être lié par ces conditions. Si vous n'acceptez pas ces termes, vous n'êtes pas autorisé à utiliser nos services.
## Description du Service
**Service Exemple** (ci-après dénommé "le Service") est une solution basée sur le web offerte par **l'Entreprise Exemple** (ci-après dénommée "l'Entreprise"). Notre service offre aux utilisateurs un accès à la [documentation](https://exemple.com/docs) et un support pour gérer efficacement leurs projets.
## Modifications des Conditions de Service
L'Entreprise se réserve le droit de modifier ces conditions à tout moment. De telles modifications entreront en vigueur immédiatement après la publication des termes mis à jour sur notre site web. Votre utilisation continue du Service après de tels changements constitue votre consentement à ces modifications.
## Inscription au Compte
Vous devrez peut-être vous inscrire au Service pour accéder à certaines fonctionnalités. Lors de l'inscription, vous acceptez de fournir des informations précises, actuelles et complètes vous concernant, comme demandé.
## Responsabilités des Utilisateurs
- **Sécurité des Données** : Les utilisateurs sont responsables de la sauvegarde de leurs identifiants de connexion et ne doivent divulguer leurs mots de passe à aucun tiers.
- **Utilisation Acceptable** : Les utilisateurs sont censés utiliser le Service de manière responsable qui ne porte pas atteinte aux droits d'autrui.
- **Propriété du Contenu** : Les utilisateurs conservent tous les droits sur le contenu qu'ils téléchargent sur le Service mais accordent à l'Entreprise une licence pour utiliser et distribuer ce contenu dans le cadre du Service.
## Propriété Intellectuelle
Tous les droits de propriété intellectuelle relatifs au Service et à son contenu original, fonctionnalités et fonctionnement sont détenus par l'Entreprise.
## Résiliation
L'Entreprise peut résilier ou suspendre votre accès à notre Service immédiatement, sans préavis ni responsabilité, pour quelque raison que ce soit, y compris, sans limitation, en cas de violation de ces Conditions.
## Loi Applicable
Ces Conditions seront régies et interprétées conformément aux lois de [Votre Pays], sans égard à ses dispositions de conflit de lois.
## Informations de Contact
Pour toute question concernant ces Conditions, veuillez nous contacter à [support@exemple.com](mailto:support@exemple.com) ou visitez notre [page FAQ](https://exemple.com/faq).
## Modifications des Conditions de Service
Nous nous réservons le droit, à notre seule discrétion, de modifier ou de remplacer ces Conditions à tout moment. Si une révision est importante, nous vous fournirons un préavis d'au moins 30 jours avant que les nouveaux termes prennent effet.
## Date d'Effet
Ces conditions sont effectives à partir du **[Insérer la Date]**.

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/contributing) that will help you get started.
Please refers to [this documentation page](https://docs.keycloakify.dev/faq-and-help/contributing) that will help you get started.

View File

@ -41,7 +41,7 @@
<img width="400" src="https://github.com/user-attachments/assets/6bf3bef9-00b0-4460-97b9-0d2da8500798">
</p>
Keycloakify is fully compatible with Keycloak 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, [~~22~~](https://github.com/keycloakify/keycloakify/issues/389#issuecomment-1822509763), 23, 24, 25...[and beyond](https://github.com/keycloakify/keycloakify/discussions/346#discussioncomment-5889791)
Keycloakify is fully compatible with Keycloak from version 11 to 26...[and beyond](https://github.com/keycloakify/keycloakify/discussions/346#discussioncomment-5889791)
## Sponsors
@ -132,6 +132,11 @@ 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/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>
</tr>
</tbody>
</table>

View File

@ -1,14 +1,14 @@
{
"name": "keycloakify",
"version": "10.0.2",
"description": "Create Keycloak themes using React",
"version": "11.3.1",
"description": "Framework to create custom Keycloak UIs",
"repository": {
"type": "git",
"url": "git://github.com/keycloakify/keycloakify.git"
},
"scripts": {
"prepare": "tsx scripts/generate-i18n-messages.ts",
"build": "tsx scripts/build.ts",
"build": "tsx scripts/build/main.ts",
"storybook": "tsx scripts/start-storybook.ts",
"link-in-starter": "tsx scripts/link-in-starter.ts",
"test": "yarn test:types && vitest run",
@ -38,12 +38,14 @@
"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/*.d.ts",
"dist/bin/shared/*.js.map",
"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",
@ -62,12 +64,13 @@
],
"homepage": "https://www.keycloakify.dev",
"dependencies": {
"tsafe": "^1.6.6"
"tsafe": "^1.7.5"
},
"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",
@ -75,20 +78,27 @@
"@storybook/builder-webpack5": "^6.5.13",
"@storybook/manager-webpack5": "^6.5.13",
"@storybook/react": "^6.5.13",
"eslint-plugin-storybook": "^0.6.7",
"@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",
"eslint-plugin-storybook": "^0.6.7",
"evt": "^2.5.7",
"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",
@ -103,12 +113,13 @@
"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",
"yauzl": "^2.10.0",
"zod": "^3.17.10",
"evt": "^2.5.7",
"tsx": "^4.15.5"
"zod": "^3.17.10"
}
}

View File

@ -1,16 +1,4 @@
import * as child_process from "child_process";
import { copyKeycloakResourcesToStorybookStaticDir } from "./copyKeycloakResourcesToStorybookStaticDir";
import { run } from "./shared/run";
(async () => {
run("yarn build");
await copyKeycloakResourcesToStorybookStaticDir();
run("npx build-storybook");
})();
function run(command: string, options?: { env?: NodeJS.ProcessEnv }) {
console.log(`$ ${command}`);
child_process.execSync(command, { stdio: "inherit", ...options });
}
run("yarn build");
run("npx build-storybook");

View File

@ -1,176 +0,0 @@
import * as child_process from "child_process";
import * as fs from "fs";
import { join } from "path";
import { assert } from "tsafe/assert";
import { transformCodebase } from "../src/bin/tools/transformCodebase";
import chalk from "chalk";
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")} -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")}`);
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")} -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 { hasBeenPatched } = patchDeprecatedBufferApiUsage(
join("dist", "vite-plugin", "index.js")
);
assert(hasBeenPatched);
}
fs.rmSync(join("dist", "src"), { recursive: true, force: true });
fs.cpSync("src", join("dist", "src"), { recursive: true });
transformCodebase({
srcDirPath: join("stories"),
destDirPath: join("dist", "stories"),
transformSourceCode: ({ fileRelativePath, sourceCode }) => {
if (!fileRelativePath.endsWith(".stories.tsx")) {
return undefined;
}
return { modifiedSourceCode: sourceCode };
}
});
console.log(chalk.green(`✓ built in ${((Date.now() - startTime) / 1000).toFixed(2)}s`));
function run(command: string) {
console.log(chalk.grey(`$ ${command}`));
child_process.execSync(command, { stdio: "inherit" });
}
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

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

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

182
scripts/build/main.ts Normal file
View File

@ -0,0 +1,182 @@
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";
(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")} -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")} -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 });
}
await createPublicKeycloakifyDevResourcesDir();
await createAccountV1Dir();
transformCodebase({
srcDirPath: join("stories"),
destDirPath: join("dist", "stories"),
transformSourceCode: ({ fileRelativePath, sourceCode }) => {
if (!fileRelativePath.endsWith(".stories.tsx")) {
return undefined;
}
return { modifiedSourceCode: sourceCode };
}
});
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

@ -0,0 +1,101 @@
import * as fs from "fs";
import {
join as pathJoin,
relative as pathRelative,
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(
[
`const path = require('path');`,
``,
`module.exports = {`,
` mode: 'production',`,
` entry: '${filePath}',`,
` output: {`,
` path: '${webpackOutputDirPath}',`,
` 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 ${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,18 +0,0 @@
import { join as pathJoin } from "path";
import { copyKeycloakResourcesToPublic } from "../src/bin/shared/copyKeycloakResourcesToPublic";
import { getProxyFetchOptions } from "../src/bin/tools/fetchProxyOptions";
import { LOGIN_THEME_RESOURCES_FROMkEYCLOAK_VERSION_DEFAULT } from "../src/bin/shared/constants";
export async function copyKeycloakResourcesToStorybookStaticDir() {
await copyKeycloakResourcesToPublic({
buildContext: {
cacheDirPath: pathJoin(__dirname, "..", "node_modules", ".cache", "scripts"),
fetchOptions: getProxyFetchOptions({
npmConfigGetCwd: pathJoin(__dirname, "..")
}),
loginThemeResourcesFromKeycloakVersion:
LOGIN_THEME_RESOURCES_FROMkEYCLOAK_VERSION_DEFAULT,
publicDirPath: pathJoin(__dirname, "..", ".storybook", "static")
}
});
}

View File

@ -6,6 +6,7 @@ import chalk from "chalk";
import { Deferred } from "evt/tools/Deferred";
import { assert } from "tsafe/assert";
import { is } from "tsafe/is";
import { run } from "./shared/run";
(async () => {
{
@ -84,9 +85,3 @@ import { is } from "tsafe/is";
console.log(`${chalk.green(`✓ Exported realm to`)} ${chalk.bold(targetFilePath)}`);
})();
function run(command: string) {
console.log(chalk.grey(`$ ${command}`));
return child_process.execSync(command, { stdio: "inherit" });
}

View File

@ -1,4 +1,3 @@
import "minimal-polyfills/Object.fromEntries";
import * as fs from "fs";
import {
join as pathJoin,
@ -6,83 +5,93 @@ import {
dirname as pathDirname,
sep as pathSep
} from "path";
import { assert } from "tsafe/assert";
import { assert, type Equals } from "tsafe/assert";
import { same } from "evt/tools/inDepth";
import { crawl } from "../src/bin/tools/crawl";
import { downloadKeycloakDefaultTheme } from "../src/bin/shared/downloadKeycloakDefaultTheme";
import { downloadKeycloakDefaultTheme } from "./shared/downloadKeycloakDefaultTheme";
import { getThisCodebaseRootDirPath } from "../src/bin/tools/getThisCodebaseRootDirPath";
import { deepAssign } from "../src/tools/deepAssign";
import { getProxyFetchOptions } from "../src/bin/tools/fetchProxyOptions";
import { THEME_TYPES } from "../src/bin/shared/constants";
import { transformCodebase } from "../src/bin/tools/transformCodebase";
import propertiesParser from "properties-parser";
// NOTE: To run without argument when we want to generate src/i18n/generated_kcMessages files,
// update the version array for generating for newer version.
//@ts-ignore
const propertiesParser = require("properties-parser");
async function main() {
const keycloakVersion = "24.0.4";
if (require.main === module) {
generateI18nMessages();
}
async function generateI18nMessages() {
const thisCodebaseRootDirPath = getThisCodebaseRootDirPath();
const { defaultThemeDirPath } = await downloadKeycloakDefaultTheme({
keycloakVersion,
buildContext: {
cacheDirPath: pathJoin(
thisCodebaseRootDirPath,
"node_modules",
".cache",
"keycloakify"
),
fetchOptions: getProxyFetchOptions({
npmConfigGetCwd: thisCodebaseRootDirPath
})
}
});
const accountI18nDirPath = pathJoin(
thisCodebaseRootDirPath,
"src",
"account",
"i18n"
);
if (fs.existsSync(accountI18nDirPath)) {
fs.rmSync(accountI18nDirPath, { recursive: true });
}
type Dictionary = { [idiomId: string]: string };
const record: { [typeOfPage: string]: { [language: string]: Dictionary } } = {};
const record: { [themeType: string]: { [language: string]: Dictionary } } = {};
{
const baseThemeDirPath = pathJoin(defaultThemeDirPath, "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);
if (match === null) {
return;
}
const [, typeOfPage, language] = match;
(record[typeOfPage] ??= {})[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])
);
for (const themeType of THEME_TYPES) {
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>>();
})()
});
}
Object.keys(record).forEach(themeType => {
if (themeType !== "login" && themeType !== "account") {
return;
{
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);
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];
@ -99,6 +108,29 @@ async function main() {
assert(false);
})();
/* Migration helper
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
@ -118,6 +150,26 @@ async function main() {
"messages_defaultSet"
);
if (!fs.existsSync(messagesDirPath)) {
fs.mkdirSync(messagesDirPath, { recursive: true });
}
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 generatedFileHeader = [
`//This code was automatically generated by running ${pathRelative(
thisCodebaseRootDirPath,
@ -180,6 +232,18 @@ async function main() {
"utf8"
)
);
}
transformCodebase({
srcDirPath: pathJoin(thisCodebaseRootDirPath, "src", "login", "i18n"),
destDirPath: accountI18nDirPath,
transformSourceCode: ({ fileRelativePath, sourceCode }) => {
if (fileRelativePath.startsWith("messages_defaultSet")) {
return undefined;
}
return { modifiedSourceCode: sourceCode };
}
});
}
@ -203,6 +267,7 @@ const keycloakifyExtraMessages_login: Record<
| "nl"
| "no"
| "pl"
| "pt"
| "pt-BR"
| "ru"
| "sk"
@ -210,7 +275,9 @@ const keycloakifyExtraMessages_login: Record<
| "th"
| "tr"
| "uk"
| "zh-CN",
| "ka"
| "zh-CN"
| "zh-TW",
Record<
| "shouldBeEqual"
| "shouldBeDifferent"
@ -434,6 +501,17 @@ const keycloakifyExtraMessages_login: Record<
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}",
@ -511,6 +589,17 @@ const keycloakifyExtraMessages_login: Record<
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}",
@ -521,38 +610,49 @@ const keycloakifyExtraMessages_login: Record<
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<
| "en"
| "ar"
| "ca"
| "cs"
| "da"
| "de"
| "el"
| "es"
| "fa"
| "fi"
| "fr"
| "hu"
| "it"
| "ja"
| "lt"
| "lv"
| "nl"
| "no"
| "pl"
| "pt-BR"
| "ru"
| "sk"
| "sv"
| "th"
| "tr"
| "uk"
| "zh-CN",
(typeof accountMultiPageSupportedLanguages)[number],
Record<"newPasswordSameAsOld" | "passwordConfirmNotMatch", string>
> = {
en: {
@ -580,18 +680,10 @@ const keycloakifyExtraMessages_account: Record<
newPasswordSameAsOld: "Das neue Passwort muss sich vom alten unterscheiden",
passwordConfirmNotMatch: "Passwortbestätigung stimmt nicht überein"
},
el: {
newPasswordSameAsOld: "Ο νέος κωδικός πρόσβασης πρέπει να διαφέρει από τον παλιό",
passwordConfirmNotMatch: "Η επιβεβαίωση του κωδικού πρόσβασης δεν ταιριάζει"
},
es: {
newPasswordSameAsOld: "La nueva contraseña debe ser diferente de la anterior",
passwordConfirmNotMatch: "La confirmación de la contraseña no coincide"
},
fa: {
newPasswordSameAsOld: "رمز عبور جدید باید با رمز عبور قبلی متفاوت باشد",
passwordConfirmNotMatch: "تأیید رمز عبور مطابقت ندارد"
},
fi: {
newPasswordSameAsOld: "Uusi salasana on oltava erilainen kuin vanha",
passwordConfirmNotMatch: "Salasanan vahvistus ei täsmää"
@ -649,25 +741,13 @@ const keycloakifyExtraMessages_account: Record<
newPasswordSameAsOld: "Det nya lösenordet måste skilja sig från det gamla",
passwordConfirmNotMatch: "Lösenordsbekräftelsen matchar inte"
},
th: {
newPasswordSameAsOld: "รหัสผ่านใหม่ต้องต่างจากรหัสผ่านเดิม",
passwordConfirmNotMatch: "การยืนยันรหัสผ่านไม่ตรงกัน"
},
tr: {
newPasswordSameAsOld: "Yeni şifre eskisinden farklı olmalıdır",
passwordConfirmNotMatch: "Şifre doğrulama eşleşmiyor"
},
uk: {
newPasswordSameAsOld: "Новий пароль повинен відрізнятися від старого",
passwordConfirmNotMatch: "Підтвердження пароля не співпадає"
},
"zh-CN": {
newPasswordSameAsOld: "新密码必须与旧密码不同",
passwordConfirmNotMatch: "密码确认不匹配"
}
/* spell-checker: enable */
};
if (require.main === module) {
main();
}

View File

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

View File

@ -1,8 +1,8 @@
import * as child_process from "child_process";
import * as fs from "fs";
import { join } from "path";
import { startRebuildOnSrcChange } from "./startRebuildOnSrcChange";
import { startRebuildOnSrcChange } from "./shared/startRebuildOnSrcChange";
import { crawl } from "../src/bin/tools/crawl";
import { run } from "./shared/run";
{
const dirPath = "node_modules";
@ -47,9 +47,3 @@ run("yarn install", { cwd: join("..", starterName) });
run(`npx tsx ${join("scripts", "link-in-app.ts")} ${starterName}`);
startRebuildOnSrcChange();
function run(command: string, options?: { cwd: string }) {
console.log(`$ ${command}`);
child_process.execSync(command, { stdio: "inherit", ...options });
}

View File

@ -0,0 +1,9 @@
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,30 +1,33 @@
import { join as pathJoin, relative as pathRelative } from "path";
import { type BuildContext } from "./buildContext";
import { assert } from "tsafe/assert";
import { LAST_KEYCLOAK_VERSION_WITH_ACCOUNT_V1 } from "./constants";
import { downloadAndExtractArchive } from "../tools/downloadAndExtractArchive";
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";
export type BuildContextLike = {
cacheDirPath: string;
fetchOptions: BuildContext["fetchOptions"];
};
assert<BuildContext extends BuildContextLike ? true : false>();
const KEYCLOAK_VERSION = {
FOR_LOGIN_THEME: "25.0.4",
FOR_ACCOUNT_MULTI_PAGE: "21.1.2"
} as const;
export async function downloadKeycloakDefaultTheme(params: {
keycloakVersion: string;
buildContext: BuildContextLike;
}): Promise<{ defaultThemeDirPath: string }> {
const { keycloakVersion, buildContext } = 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: buildContext.cacheDirPath,
fetchOptions: buildContext.fetchOptions,
uniqueIdOfOnArchiveFile: "downloadKeycloakDefaultTheme",
cacheDirPath,
fetchOptions: getProxyFetchOptions({
npmConfigGetCwd: getThisCodebaseRootDirPath()
}),
uniqueIdOfOnArchiveFile: "extractOnlyRequiredFiles",
onArchiveFile: async params => {
const fileRelativePath = pathRelative("theme", params.fileRelativePath);
@ -34,16 +37,44 @@ export async function downloadKeycloakDefaultTheme(params: {
const { readFile, writeFile } = params;
skip_keycloak_v2: {
if (!fileRelativePath.startsWith(pathJoin("keycloak.v2"))) {
break skip_keycloak_v2;
}
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 (LAST_KEYCLOAK_VERSION_WITH_ACCOUNT_V1 !== keycloakVersion) {
if (keycloakVersion !== KEYCLOAK_VERSION.FOR_ACCOUNT_MULTI_PAGE) {
break last_account_v1_transformations;
}
@ -169,7 +200,7 @@ export async function downloadKeycloakDefaultTheme(params: {
}
skip_unused_resources: {
if (keycloakVersion !== "24.0.4") {
if (keycloakVersion !== KEYCLOAK_VERSION.FOR_LOGIN_THEME) {
break skip_unused_resources;
}
@ -250,7 +281,8 @@ export async function downloadKeycloakDefaultTheme(params: {
"OpenSans-Semibold-webfont.woff2"
),
pathJoin("patternfly", "dist", "img", "bg-login.jpg"),
pathJoin("jquery", "dist", "jquery.min.js")
pathJoin("jquery", "dist", "jquery.min.js"),
pathJoin("rfc4648", "lib", "rfc4648.js")
]);
}
@ -287,11 +319,21 @@ export async function downloadKeycloakDefaultTheme(params: {
return;
}
skip_package_json: {
if (
fileRelativePath !==
pathJoin("keycloak", "common", "resources", "package.json")
) {
break skip_package_json;
}
return;
}
}
await writeFile({ fileRelativePath });
}
});
return { defaultThemeDirPath: extractedDirPath };
return { extractedDirPath };
}

8
scripts/shared/run.ts Normal file
View File

@ -0,0 +1,8 @@
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,12 +1,10 @@
import * as child_process from "child_process";
import { startRebuildOnSrcChange } from "./startRebuildOnSrcChange";
import { copyKeycloakResourcesToStorybookStaticDir } from "./copyKeycloakResourcesToStorybookStaticDir";
import { startRebuildOnSrcChange } from "./shared/startRebuildOnSrcChange";
import { run } from "./shared/run";
(async () => {
run("yarn build");
await copyKeycloakResourcesToStorybookStaticDir();
{
const child = child_process.spawn("npx", ["start-storybook", "-p", "6006"], {
shell: true
@ -21,9 +19,3 @@ import { copyKeycloakResourcesToStorybookStaticDir } from "./copyKeycloakResourc
startRebuildOnSrcChange();
})();
function run(command: string, options?: { env?: NodeJS.ProcessEnv }) {
console.log(`$ ${command}`);
child_process.execSync(command, { stdio: "inherit", ...options });
}

View File

@ -1,4 +1,4 @@
import { BASENAME_OF_KEYCLOAKIFY_RESOURCES_DIR } from "keycloakify/bin/shared/constants";
import { WELL_KNOWN_DIRECTORY_BASE_NAME } from "keycloakify/bin/shared/constants";
import { assert } from "tsafe/assert";
/**
@ -6,7 +6,9 @@ import { assert } from "tsafe/assert";
* This works both in your main app and in your Keycloak theme.
*/
export const PUBLIC_URL = (() => {
const kcContext = (window as any).kcContext;
const kcContext: { "x-keycloakify": { resourcesPath: string } } | undefined = (
window as any
).kcContext;
if (kcContext === undefined || process.env.NODE_ENV === "development") {
assert(
@ -17,5 +19,5 @@ export const PUBLIC_URL = (() => {
return process.env.PUBLIC_URL;
}
return `${kcContext.url.resourcesPath}/${BASENAME_OF_KEYCLOAKIFY_RESOURCES_DIR}`;
return `${kcContext["x-keycloakify"].resourcesPath}/${WELL_KNOWN_DIRECTORY_BASE_NAME.DIST}`;
})();

View File

@ -1,10 +1,12 @@
import "keycloakify/tools/Object.fromEntries";
import { RESOURCES_COMMON, KEYCLOAK_RESOURCES } from "keycloakify/bin/shared/constants";
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}${KEYCLOAK_RESOURCES}/account/resources`;
const resourcesPath = `${BASE_URL}${WELL_KNOWN_DIRECTORY_BASE_NAME.KEYCLOAKIFY_DEV_RESOURCES}/account`;
export const kcContextCommonMock: KcContext.Common = {
themeVersion: "0.0.0",
@ -13,7 +15,7 @@ export const kcContextCommonMock: KcContext.Common = {
themeName: "my-theme-name",
url: {
resourcesPath,
resourcesCommonPath: `${resourcesPath}/${RESOURCES_COMMON}`,
resourcesCommonPath: `${resourcesPath}/${WELL_KNOWN_DIRECTORY_BASE_NAME.RESOURCES_COMMON}`,
resourceUrl: "#",
accountUrl: "#",
applicationsUrl: "#",
@ -38,35 +40,53 @@ export const kcContextCommonMock: KcContext.Common = {
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"]
/* spell-checker: enable */
].map(
([languageTag, label]) =>
({
languageTag,
label,
url: "https://gist.github.com/garronej/52baaca1bb925f2296ab32741e062b8e"
}) as const
),
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: {

View File

@ -1,9 +1,9 @@
import { useEffect } from "react";
import { assert } from "keycloakify/tools/assert";
import { clsx } from "keycloakify/tools/clsx";
import { kcSanitize } from "keycloakify/lib/kcSanitize";
import { getKcClsx } from "keycloakify/account/lib/kcClsx";
import { useInsertLinkTags } from "keycloakify/tools/useInsertLinkTags";
import { useSetClassName } from "keycloakify/tools/useSetClassName";
import { useInitialize } from "keycloakify/account/Template.useInitialize";
import type { TemplateProps } from "keycloakify/account/TemplateProps";
import type { I18n } from "./i18n";
import type { KcContext } from "./KcContext";
@ -13,9 +13,9 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
const { kcClsx } = getKcClsx({ doUseDefaultCss, classes });
const { msg, msgStr, getChangeLocaleUrl, labelBySupportedLanguageTag, currentLanguageTag } = i18n;
const { msg, msgStr, currentLanguage, enabledLanguages } = i18n;
const { locale, url, features, realm, message, referrer } = kcContext;
const { url, features, realm, message, referrer } = kcContext;
useEffect(() => {
document.title = msgStr("accountManagementTitle");
@ -31,30 +31,9 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
className: clsx("admin-console", "user", kcClsx("kcBodyClass"))
});
useEffect(() => {
const { currentLanguageTag } = locale ?? {};
const { isReadyToRender } = useInitialize({ kcContext, doUseDefaultCss });
if (currentLanguageTag === undefined) {
return;
}
const html = document.querySelector("html");
assert(html !== null);
html.lang = currentLanguageTag;
}, []);
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`
]
});
if (!areAllStyleSheetsLoaded) {
if (!isReadyToRender) {
return null;
}
@ -70,16 +49,16 @@ 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">
{realm.internationalizationEnabled && (assert(locale !== undefined), true) && locale.supported.length > 1 && (
{enabledLanguages.length > 1 && (
<li>
<div className="kc-dropdown" id="kc-locale-dropdown">
<a href="#" id="kc-current-locale-link">
{labelBySupportedLanguageTag[currentLanguageTag]}
{currentLanguage.label}
</a>
<ul>
{locale.supported.map(({ languageTag }) => (
{enabledLanguages.map(({ languageTag, label, href }) => (
<li key={languageTag} className="kc-dropdown-item">
<a href={getChangeLocaleUrl(languageTag)}>{labelBySupportedLanguageTag[languageTag]}</a>
<a href={href}>{label}</a>
</li>
))}
</ul>
@ -148,7 +127,7 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
<span
className="kc-feedback-text"
dangerouslySetInnerHTML={{
__html: message.summary
__html: kcSanitize(message.summary)
}}
/>
</div>

View File

@ -0,0 +1,35 @@
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,4 +1,5 @@
import type { ReactNode } from "react";
import type { ClassKey } from "keycloakify/account/lib/kcClsx";
export type TemplateProps<KcContext, I18n> = {
kcContext: KcContext;
@ -10,17 +11,4 @@ export type TemplateProps<KcContext, I18n> = {
active: string;
};
export type ClassKey =
| "kcHtmlClass"
| "kcBodyClass"
| "kcButtonClass"
| "kcButtonPrimaryClass"
| "kcButtonLargeClass"
| "kcButtonDefaultClass"
| "kcContentWrapperClass"
| "kcFormClass"
| "kcFormGroupClass"
| "kcInputWrapperClass"
| "kcLabelClass"
| "kcInputClass"
| "kcInputErrorMessageClass";
export type { ClassKey };

View File

@ -1,6 +0,0 @@
import type { GenericI18n_noJsx } from "./i18n";
export type GenericI18n<MessageKey extends string> = GenericI18n_noJsx<MessageKey> & {
msg: (key: MessageKey, ...args: (string | undefined)[]) => JSX.Element;
advancedMsg: (key: string, ...args: (string | undefined)[]) => JSX.Element;
};

View File

@ -1,250 +0,0 @@
import "keycloakify/tools/Object.fromEntries";
import { assert } from "tsafe/assert";
import messages_defaultSet_fallbackLanguage from "./messages_defaultSet/en";
import { fetchMessages_defaultSet } from "./messages_defaultSet";
import type { KcContext } from "../KcContext";
import { FALLBACK_LANGUAGE_TAG } from "keycloakify/bin/shared/constants";
import { id } from "tsafe/id";
export type KcContextLike = {
locale?: {
currentLanguageTag: string;
supported: { languageTag: string; url: string; label: string }[];
};
"x-keycloakify": {
messages: Record<string, string>;
};
};
assert<KcContext extends KcContextLike ? true : false>();
export type GenericI18n_noJsx<MessageKey extends string> = {
/**
* e.g: "en", "fr", "zh-CN"
*
* The current language
*/
currentLanguageTag: string;
/**
* Redirect to this url to change the language.
* After reload currentLanguageTag === newLanguageTag
*/
getChangeLocaleUrl: (newLanguageTag: string) => string;
/**
* 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"
* {
* en: {
* "access-denied": "Access denied",
* "impersonateTitleHtml": "<strong>{0}</strong> Impersonate User",
* "bar": "Bar {0}"
* }
* }
*
* msgStr("access-denied") === "Access denied"
* msgStr("not-a-message-key") Throws an error
* msgStr("impersonateTitleHtml", "Foo") === "<strong>Foo</strong> Impersonate User"
* msgStr("${bar}", "<strong>c</strong>") === "Bar &lt;strong&gt;XXX&lt;/strong&gt;"
* The html in the arg is partially escaped for security reasons, it might come from an untrusted source, it's not safe to render it as html.
*/
msgStr: (key: MessageKey, ...args: (string | undefined)[]) => string;
/**
* This is meant to be used when the key argument is variable, something that might have been configured by the user
* in the Keycloak admin for example.
*
* Examples assuming currentLanguageTag === "en"
* {
* en: {
* "access-denied": "Access denied",
* }
* }
*
* advancedMsgStr("${access-denied}") === advancedMsgStr("access-denied") === msgStr("access-denied") === "Access denied"
* advancedMsgStr("${not-a-message-key}") === advancedMsgStr("not-a-message-key") === "not-a-message-key"
*/
advancedMsgStr: (key: string, ...args: (string | undefined)[]) => string;
/**
* Initially the messages are in english (fallback language).
* The translations in the current language are being fetched dynamically.
* This property is true while the translations are being fetched.
*/
isFetchingTranslations: boolean;
};
export type MessageKey_defaultSet = keyof typeof messages_defaultSet_fallbackLanguage;
export function createGetI18n<MessageKey_themeDefined extends string = never>(messagesByLanguageTag_themeDefined: {
[languageTag: string]: { [key in MessageKey_themeDefined]: string };
}) {
type I18n = GenericI18n_noJsx<MessageKey_defaultSet | MessageKey_themeDefined>;
type Result = { i18n: I18n; prI18n_currentLanguage: Promise<I18n> | undefined };
const cachedResultByKcContext = new WeakMap<KcContextLike, Result>();
function getI18n(params: { kcContext: KcContextLike }): Result {
const { kcContext } = params;
use_cache: {
const cachedResult = cachedResultByKcContext.get(kcContext);
if (cachedResult === undefined) {
break use_cache;
}
return cachedResult;
}
const partialI18n: Pick<I18n, "currentLanguageTag" | "getChangeLocaleUrl" | "labelBySupportedLanguageTag"> = {
currentLanguageTag: kcContext.locale?.currentLanguageTag ?? FALLBACK_LANGUAGE_TAG,
getChangeLocaleUrl: 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`);
return targetSupportedLocale.url;
},
labelBySupportedLanguageTag: Object.fromEntries((kcContext.locale?.supported ?? []).map(({ languageTag, label }) => [languageTag, label]))
};
const { createI18nTranslationFunctions } = createI18nTranslationFunctionsFactory<MessageKey_themeDefined>({
messages_themeDefined:
messagesByLanguageTag_themeDefined[partialI18n.currentLanguageTag] ??
messagesByLanguageTag_themeDefined[FALLBACK_LANGUAGE_TAG] ??
(() => {
const firstLanguageTag = Object.keys(messagesByLanguageTag_themeDefined)[0];
if (firstLanguageTag === undefined) {
return undefined;
}
return messagesByLanguageTag_themeDefined[firstLanguageTag];
})(),
messages_fromKcServer: kcContext["x-keycloakify"].messages
});
const isCurrentLanguageFallbackLanguage = partialI18n.currentLanguageTag === FALLBACK_LANGUAGE_TAG;
const result: Result = {
i18n: {
...partialI18n,
...createI18nTranslationFunctions({
messages_defaultSet_currentLanguage: isCurrentLanguageFallbackLanguage ? messages_defaultSet_fallbackLanguage : undefined
}),
isFetchingTranslations: !isCurrentLanguageFallbackLanguage
},
prI18n_currentLanguage: isCurrentLanguageFallbackLanguage
? undefined
: (async () => {
const messages_defaultSet_currentLanguage = await fetchMessages_defaultSet(partialI18n.currentLanguageTag);
const i18n_currentLanguage: I18n = {
...partialI18n,
...createI18nTranslationFunctions({ messages_defaultSet_currentLanguage }),
isFetchingTranslations: false
};
// NOTE: This promise.resolve is just because without it we TypeScript
// gives a Variable 'result' is used before being assigned. error
await Promise.resolve().then(() => {
result.i18n = i18n_currentLanguage;
result.prI18n_currentLanguage = undefined;
});
return i18n_currentLanguage;
})()
};
cachedResultByKcContext.set(kcContext, result);
return result;
}
return { getI18n };
}
function createI18nTranslationFunctionsFactory<MessageKey_themeDefined extends string>(params: {
messages_themeDefined: Record<MessageKey_themeDefined, string> | undefined;
messages_fromKcServer: Record<string, string>;
}) {
const { messages_themeDefined, messages_fromKcServer } = params;
function createI18nTranslationFunctions(params: {
messages_defaultSet_currentLanguage: Partial<Record<MessageKey_defaultSet, string>> | undefined;
}): Pick<GenericI18n_noJsx<MessageKey_defaultSet | MessageKey_themeDefined>, "msgStr" | "advancedMsgStr"> {
const { messages_defaultSet_currentLanguage } = params;
function resolveMsg(props: { key: string; args: (string | undefined)[] }): string | undefined {
const { key, args } = props;
const message =
id<Record<string, string | undefined>>(messages_fromKcServer)[key] ??
id<Record<string, string | undefined> | undefined>(messages_themeDefined)?.[key] ??
id<Record<string, string | undefined> | undefined>(messages_defaultSet_currentLanguage)?.[key] ??
id<Record<string, string | undefined>>(messages_defaultSet_fallbackLanguage)[key];
if (message === undefined) {
return undefined;
}
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.replace(/</g, "&lt;").replace(/>/g, "&gt;")
);
});
return messageWithArgsInjected;
}
function resolveMsgAdvanced(props: { key: string; args: (string | undefined)[] }): string {
const { key, args } = props;
const match = key.match(/^\$\{(.+)\}$/);
if (match === null) {
return key;
}
return resolveMsg({ key: match[1], args }) ?? key;
}
return {
msgStr: (key, ...args) => {
const resolvedMessage = resolveMsg({ key, args });
assert(resolvedMessage !== undefined, `Message with key "${key}" not found`);
return resolvedMessage;
},
advancedMsgStr: (key, ...args) => resolveMsgAdvanced({ key, args })
};
}
return { createI18nTranslationFunctions };
}

View File

@ -1,5 +0,0 @@
import type { GenericI18n } from "./GenericI18n";
import type { MessageKey_defaultSet, KcContextLike } from "./i18n";
export type { MessageKey_defaultSet, KcContextLike };
export type I18n = GenericI18n<MessageKey_defaultSet>;
export { createUseI18n } from "./useI18n";

View File

@ -1,3 +1,3 @@
export type { ExtendKcContext } from "keycloakify/account/KcContext";
export type { ClassKey } from "keycloakify/account/TemplateProps";
export { createUseI18n } from "keycloakify/account/i18n";
export { i18nBuilder, type MessageKey_defaultSet } from "keycloakify/account/i18n";

View File

@ -1,5 +1,19 @@
import { createGetKcClsx } from "keycloakify/lib/getKcClsx";
import type { ClassKey } from "keycloakify/account/TemplateProps";
export type ClassKey =
| "kcHtmlClass"
| "kcBodyClass"
| "kcButtonClass"
| "kcButtonPrimaryClass"
| "kcButtonLargeClass"
| "kcButtonDefaultClass"
| "kcContentWrapperClass"
| "kcFormClass"
| "kcFormGroupClass"
| "kcInputWrapperClass"
| "kcLabelClass"
| "kcInputClass"
| "kcInputErrorMessageClass";
export const { getKcClsx } = createGetKcClsx<ClassKey>({
defaultClasses: {
@ -20,6 +34,4 @@ export const { getKcClsx } = createGetKcClsx<ClassKey>({
}
});
export type { ClassKey };
export type KcClsx = ReturnType<typeof getKcClsx>["kcClsx"];

View File

@ -1,5 +1,6 @@
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";
@ -159,7 +160,7 @@ export default function Totp(props: PageProps<Extract<KcContext, { pageId: "totp
className={kcClsx("kcInputErrorMessageClass")}
aria-live="polite"
dangerouslySetInnerHTML={{
__html: messagesPerField.get("totp")
__html: kcSanitize(messagesPerField.get("totp"))
}}
/>
)}
@ -190,7 +191,7 @@ export default function Totp(props: PageProps<Extract<KcContext, { pageId: "totp
className={kcClsx("kcInputErrorMessageClass")}
aria-live="polite"
dangerouslySetInnerHTML={{
__html: messagesPerField.get("userLabel")
__html: kcSanitize(messagesPerField.get("userLabel"))
}}
/>
)}

View File

@ -13,16 +13,11 @@ 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 { CliCommandOptions } from "./main";
import { getBuildContext } from "./shared/buildContext";
import type { BuildContext } from "./shared/buildContext";
import chalk from "chalk";
export async function command(params: { cliCommandOptions: CliCommandOptions }) {
const { cliCommandOptions } = params;
const buildContext = getBuildContext({
cliCommandOptions
});
export async function command(params: { buildContext: BuildContext }) {
const { buildContext } = params;
console.log(chalk.cyan("Theme type:"));

View File

@ -1,13 +1,16 @@
import { copyKeycloakResourcesToPublic } from "./shared/copyKeycloakResourcesToPublic";
import { getBuildContext } from "./shared/buildContext";
import type { CliCommandOptions } from "./main";
import type { BuildContext } from "./shared/buildContext";
import { maybeDelegateCommandToCustomHandler } from "./shared/customHandler_delegate";
export async function command(params: { cliCommandOptions: CliCommandOptions }) {
const { cliCommandOptions } = params;
export async function command(params: { buildContext: BuildContext }) {
const { buildContext } = params;
const buildContext = getBuildContext({ cliCommandOptions });
maybeDelegateCommandToCustomHandler({
commandName: "copy-keycloak-resources-to-public",
buildContext
});
await copyKeycloakResourcesToPublic({
copyKeycloakResourcesToPublic({
buildContext
});
}

View File

@ -20,15 +20,16 @@ import {
} from "path";
import { kebabCaseToCamelCase } from "./tools/kebabCaseToSnakeCase";
import { assert, Equals } from "tsafe/assert";
import type { CliCommandOptions } from "./main";
import { getBuildContext } from "./shared/buildContext";
import type { BuildContext } from "./shared/buildContext";
import chalk from "chalk";
import { maybeDelegateCommandToCustomHandler } from "./shared/customHandler_delegate";
export async function command(params: { cliCommandOptions: CliCommandOptions }) {
const { cliCommandOptions } = params;
export async function command(params: { buildContext: BuildContext }) {
const { buildContext } = params;
const buildContext = getBuildContext({
cliCommandOptions
maybeDelegateCommandToCustomHandler({
commandName: "eject-page",
buildContext
});
console.log(chalk.cyan("Theme type:"));
@ -244,12 +245,12 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
)} copy pasted from the Keycloakify source code into your project`
);
edit_KcApp: {
edit_KcPage: {
if (
pageIdOrComponent !== templateValue &&
pageIdOrComponent !== userProfileFormFieldsValue
) {
break edit_KcApp;
break edit_KcPage;
}
const kcAppTsxPath = pathJoin(

View File

@ -1,17 +1,20 @@
import { getBuildContext } from "../shared/buildContext";
import type { CliCommandOptions } from "../main";
import type { BuildContext } from "../shared/buildContext";
import cliSelect from "cli-select";
import child_process from "child_process";
import chalk from "chalk";
import { join as pathJoin, relative as pathRelative } from "path";
import * as fs from "fs";
import { updateAccountThemeImplementationInConfig } from "./updateAccountThemeImplementationInConfig";
import { generateKcGenTs } from "../shared/generateKcGenTs";
import { command as updateKcGenCommand } from "../update-kc-gen";
import { maybeDelegateCommandToCustomHandler } from "../shared/customHandler_delegate";
export async function command(params: { cliCommandOptions: CliCommandOptions }) {
const { cliCommandOptions } = params;
export async function command(params: { buildContext: BuildContext }) {
const { buildContext } = params;
const buildContext = getBuildContext({ cliCommandOptions });
maybeDelegateCommandToCustomHandler({
commandName: "initialize-account-theme",
buildContext
});
const accountThemeSrcDirPath = pathJoin(buildContext.themeSrcDirPath, "account");
@ -97,7 +100,7 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
updateAccountThemeImplementationInConfig({ buildContext, accountThemeType });
await generateKcGenTs({
await updateKcGenCommand({
buildContext: {
...buildContext,
implementedThemeTypes: {

View File

@ -6,6 +6,7 @@ import {
getLatestsSemVersionedTag,
type BuildContextLike as BuildContextLike_getLatestsSemVersionedTag
} from "../shared/getLatestsSemVersionedTag";
import { SemVer } from "../tools/SemVer";
import fetch from "make-fetch-happen";
import { z } from "zod";
import { assert, type Equals } from "tsafe/assert";
@ -68,7 +69,9 @@ export async function initializeAccountTheme_singlePage(params: {
})()
);
dependencies.dependencies["@keycloakify/keycloak-account-ui"] = semVersionedTag.tag;
dependencies.dependencies["@keycloakify/keycloak-account-ui"] = SemVer.stringify(
semVersionedTag.version
);
const parsedPackageJson = (() => {
type ParsedPackageJson = {

View File

@ -1,5 +1,12 @@
import { createUseI18n } from "keycloakify/account";
import { i18nBuilder } from "keycloakify/account";
import type { ThemeName } from "../kc.gen";
export const { useI18n, ofTypeI18n } = createUseI18n({});
const { useI18n, ofTypeI18n } = i18nBuilder
.withThemeName<ThemeName>()
.withExtraLanguages({})
.withCustomTranslations({})
.build();
export type I18n = typeof ofTypeI18n;
type I18n = typeof ofTypeI18n;
export { useI18n, type I18n };

View File

@ -8,12 +8,14 @@ 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: BuildContext;
buildContext: BuildContextLike;
accountThemeType: "Single-Page" | "Multi-Page";
}) {
const { buildContext, accountThemeType } = params;
@ -60,14 +62,14 @@ export function updateAccountThemeImplementationInConfig(params: {
{
const parsedPackageJson = (() => {
type ParsedPackageJson = {
keycloakify: Record<string, string>;
keycloakify: Record<string, unknown>;
};
const zParsedPackageJson = (() => {
type TargetType = ParsedPackageJson;
const zTargetType = z.object({
keycloakify: z.record(z.string())
keycloakify: z.record(z.unknown())
});
assert<Equals<z.infer<typeof zTargetType>, TargetType>>();
@ -75,17 +77,22 @@ export function updateAccountThemeImplementationInConfig(params: {
return id<z.ZodType<TargetType>>(zTargetType);
})();
return zParsedPackageJson.parse(
JSON.parse(
fs
.readFileSync(buildContext.packageJsonFilePath)
.toString("utf8")
)
const parsedPackageJson = JSON.parse(
fs.readFileSync(buildContext.packageJsonFilePath).toString("utf8")
);
zParsedPackageJson.parse(parsedPackageJson);
return parsedPackageJson;
})();
parsedPackageJson.keycloakify.accountThemeImplementation =
accountThemeType;
fs.writeFileSync(
buildContext.packageJsonFilePath,
Buffer.from(JSON.stringify(parsedPackageJson, undefined, 4), "utf8")
);
}
break;
}

View File

@ -1,21 +1,27 @@
import { downloadKeycloakDefaultTheme } from "./shared/downloadKeycloakDefaultTheme";
import { join as pathJoin, relative as pathRelative } from "path";
import { transformCodebase } from "./tools/transformCodebase";
import { promptKeycloakVersion } from "./shared/promptKeycloakVersion";
import { getBuildContext } from "./shared/buildContext";
import type { BuildContext } from "./shared/buildContext";
import * as fs from "fs";
import type { CliCommandOptions } from "./main";
import { downloadAndExtractArchive } from "./tools/downloadAndExtractArchive";
import { maybeDelegateCommandToCustomHandler } from "./shared/customHandler_delegate";
export async function command(params: { cliCommandOptions: CliCommandOptions }) {
const { cliCommandOptions } = params;
export async function command(params: { buildContext: BuildContext }) {
const { buildContext } = params;
const buildContext = getBuildContext({ cliCommandOptions });
maybeDelegateCommandToCustomHandler({
commandName: "initialize-email-theme",
buildContext
});
const emailThemeSrcDirPath = pathJoin(buildContext.themeSrcDirPath, "email");
if (fs.existsSync(emailThemeSrcDirPath)) {
if (
fs.existsSync(emailThemeSrcDirPath) &&
fs.readdirSync(emailThemeSrcDirPath).length > 0
) {
console.warn(
`There is already a ${pathRelative(
`There is already a non empty ${pathRelative(
process.cwd(),
emailThemeSrcDirPath
)} directory in your project. Aborting.`
@ -34,13 +40,27 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
buildContext
});
const { defaultThemeDirPath } = await downloadKeycloakDefaultTheme({
keycloakVersion,
buildContext
const { extractedDirPath } = await downloadAndExtractArchive({
url: `https://repo1.maven.org/maven2/org/keycloak/keycloak-themes/${keycloakVersion}/keycloak-themes-${keycloakVersion}.jar`,
cacheDirPath: buildContext.cacheDirPath,
fetchOptions: buildContext.fetchOptions,
uniqueIdOfOnArchiveFile: "extractOnlyEmailTheme",
onArchiveFile: async ({ fileRelativePath, writeFile }) => {
const fileRelativePath_target = pathRelative(
pathJoin("theme", "base", "email"),
fileRelativePath
);
if (fileRelativePath_target.startsWith("..")) {
return;
}
await writeFile({ fileRelativePath: fileRelativePath_target });
}
});
transformCodebase({
srcDirPath: pathJoin(defaultThemeDirPath, "base", "email"),
srcDirPath: extractedDirPath,
destDirPath: emailThemeSrcDirPath
});
@ -50,7 +70,10 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
fs.writeFileSync(
themePropertyFilePath,
Buffer.from(
`parent=base\n${fs.readFileSync(themePropertyFilePath).toString("utf8")}`,
[
`parent=base`,
fs.readFileSync(themePropertyFilePath).toString("utf8")
].join("\n"),
"utf8"
)
);

View File

@ -7,7 +7,6 @@ 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 { ACCOUNT_V1_THEME_NAME } from "../../shared/constants";
import {
generatePom,
BuildContextLike as BuildContextLike_generatePom
@ -17,6 +16,7 @@ import { isInside } from "../../tools/isInside";
import child_process from "child_process";
import { rmSync } from "../../tools/fs.rmSync";
import { writeMetaInfKeycloakThemes } from "../../shared/metaInfKeycloakThemes";
import { existsAsync } from "../../tools/fs.existsAsync";
export type BuildContextLike = BuildContextLike_generatePom & {
keycloakifyBuildDirPath: string;
@ -75,7 +75,7 @@ export async function buildJar(params: {
if (
isInside({
dirPath: pathJoin("theme", ACCOUNT_V1_THEME_NAME),
dirPath: pathJoin("theme", "account-v1"),
filePath: fileRelativePath
})
) {
@ -90,10 +90,7 @@ export async function buildJar(params: {
const modifiedSourceCode = Buffer.from(
sourceCode
.toString("utf8")
.replace(
`parent=${ACCOUNT_V1_THEME_NAME}`,
"parent=keycloak"
),
.replace(`parent=account-v1`, "parent=keycloak"),
"utf8"
);
@ -126,7 +123,7 @@ export async function buildJar(params: {
assert(metaInfKeycloakTheme !== undefined);
metaInfKeycloakTheme.themes = metaInfKeycloakTheme.themes.filter(
({ name }) => name !== ACCOUNT_V1_THEME_NAME
({ name }) => name !== "account-v1"
);
return metaInfKeycloakTheme;
@ -139,59 +136,49 @@ export async function buildJar(params: {
break route_legacy_pages;
}
// NOTE: If there's no account theme there is no special target for keycloak 24 and up so we create
// the pages anyway. If there is an account pages, since we know that account-v1 is only support keycloak
// 24 in version 0.4 and up, we can safely break the route for legacy pages.
const doBreak: boolean = (() => {
switch (keycloakAccountV1Version) {
case null:
return false;
case "0.3":
return false;
default:
return true;
}
})();
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
);
// TODO: Remove this optimization, it's a bit hacky.
if (doBreak) {
break route_legacy_pages;
}
// NOTE: https://github.com/keycloakify/keycloakify/issues/665
if (!(await existsAsync(ftlFilePath))) {
return;
}
(["register.ftl", "login-update-profile.ftl"] as const).forEach(pageId =>
buildContext.themeNames.map(themeName => {
const ftlFilePath = pathJoin(
tmpResourcesDirPath,
"theme",
themeName,
"login",
pageId
);
const ftlFileContent = readFileSync(ftlFilePath).toString("utf8");
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 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}"`
);
const modifiedFtlFileContent = ftlFileContent.replace(
`"ftlTemplateFileName": "${pageId}"`,
`"ftlTemplateFileName": "${ftlFileBasename}"`
);
assert(modifiedFtlFileContent !== ftlFileContent);
assert(modifiedFtlFileContent !== ftlFileContent);
fs.writeFile(
pathJoin(pathDirname(ftlFilePath), ftlFileBasename),
Buffer.from(modifiedFtlFileContent, "utf8")
);
})
await fs.writeFile(
pathJoin(pathDirname(ftlFilePath), ftlFileBasename),
Buffer.from(modifiedFtlFileContent, "utf8")
);
})
)
.flat()
);
}
@ -210,7 +197,7 @@ export async function buildJar(params: {
await new Promise<void>((resolve, reject) =>
child_process.exec(
`mvn install -Dmaven.repo.local="${pathJoin(keycloakifyBuildCacheDirPath, ".m2")}"`,
`mvn clean install -Dmaven.repo.local="${pathJoin(keycloakifyBuildCacheDirPath, ".m2")}"`,
{ cwd: keycloakifyBuildCacheDirPath },
error => {
if (error !== null) {

View File

@ -52,9 +52,9 @@ export function getKeycloakVersionRangeForJar(params: {
case "0.6":
switch (keycloakThemeAdditionalInfoExtensionVersion) {
case null:
return undefined;
return "26-and-above" as const;
case "1.1.5":
return "25-and-above" as const;
return "25" as const;
}
}
assert<Equals<typeof keycloakAccountV1Version, never>>(false);
@ -75,9 +75,9 @@ export function getKeycloakVersionRangeForJar(params: {
}
switch (keycloakThemeAdditionalInfoExtensionVersion) {
case null:
return "21-and-below";
return "all-other-versions";
case "1.1.5":
return "22-and-above";
return "22-to-25";
}
assert<Equals<typeof keycloakThemeAdditionalInfoExtensionVersion, never>>(
false

View File

@ -11,11 +11,7 @@ import * as fs from "fs";
import { join as pathJoin } from "path";
import type { BuildContext } from "../../shared/buildContext";
import { assert } from "tsafe/assert";
import {
type ThemeType,
BASENAME_OF_KEYCLOAKIFY_RESOURCES_DIR,
RESOURCES_COMMON
} from "../../shared/constants";
import { type ThemeType, WELL_KNOWN_DIRECTORY_BASE_NAME } from "../../shared/constants";
import { getThisCodebaseRootDirPath } from "../../tools/getThisCodebaseRootDirPath";
export type BuildContextLike = BuildContextLike_replaceImportsInJsCode &
@ -94,7 +90,7 @@ export function generateFtlFilesCodeFactory(params: {
new RegExp(
`^${(buildContext.urlPathname ?? "/").replace(/\//g, "\\/")}`
),
`\${xKeycloakify.resourcesPath}/${BASENAME_OF_KEYCLOAKIFY_RESOURCES_DIR}/`
`\${xKeycloakify.resourcesPath}/${WELL_KNOWN_DIRECTORY_BASE_NAME.DIST}/`
)
);
})
@ -119,7 +115,7 @@ export function generateFtlFilesCodeFactory(params: {
.replace("{{keycloakifyVersion}}", keycloakifyVersion)
.replace("{{themeVersion}}", buildContext.themeVersion)
.replace("{{fieldNames}}", fieldNames.map(name => `"${name}"`).join(", "))
.replace("{{RESOURCES_COMMON}}", RESOURCES_COMMON)
.replace("{{RESOURCES_COMMON}}", WELL_KNOWN_DIRECTORY_BASE_NAME.RESOURCES_COMMON)
.replace(
"{{userDefinedExclusions}}",
buildContext.kcContextExclusionsFtlCode ?? ""

View File

@ -85,6 +85,21 @@ attributes_to_attributesByName: {
});
}
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) {
@ -151,7 +166,7 @@ function decodeHtmlEntities(htmlStr){
areSamePath(path, []) &&
["login-idp-link-confirm.ftl", "login-idp-link-email.ftl" ]?seq_contains(xKeycloakify.pageId)
) || (
["masterAdminClient", "delegateForUpdate", "defaultRole"]?seq_contains(key) &&
["masterAdminClient", "delegateForUpdate", "defaultRole", "smtpConfig"]?seq_contains(key) &&
areSamePath(path, ["realm"])
) || (
xKeycloakify.pageId == "error.ftl" &&
@ -220,6 +235,9 @@ function decodeHtmlEntities(htmlStr){
"identityFederationEnabled",
"userManagedAccessAllowed"
]?seq_contains(key)
) || (
["flowContext", "session", "realm"]?seq_contains(key) &&
areSamePath(path, ["social"])
)
>
<#-- <#local outSeq += ["/*" + path?join(".") + "." + key + " excluded*/"]> -->

View File

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

View File

@ -1,6 +1,6 @@
import { type ThemeType, FALLBACK_LANGUAGE_TAG } from "../../shared/constants";
import { crawl } from "../../tools/crawl";
import { join as pathJoin } from "path";
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";
@ -10,12 +10,27 @@ import { escapeStringForPropertiesFile } from "../../tools/escapeStringForProper
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: {
themeSrcDirPath: string;
buildContext: BuildContextLike;
themeType: ThemeType;
}): { languageTag: string; propertiesFileSource: string }[] {
const { themeSrcDirPath, themeType } = params;
}): {
languageTags: string[];
writeMessagePropertiesFiles: (params: {
messageDirPath: string;
themeName: string;
}) => void;
} {
const { buildContext, themeType } = params;
const baseMessagesDirPath = pathJoin(
getThisCodebaseRootDirPath(),
@ -25,51 +40,49 @@ export function generateMessageProperties(params: {
"messages_defaultSet"
);
const baseMessageBundle: { [languageTag: string]: Record<string, string> } =
Object.fromEntries(
fs
.readdirSync(baseMessagesDirPath)
.filter(baseName => baseName !== "index.ts")
.map(basename => ({
languageTag: basename.replace(/\.ts$/, ""),
filePath: pathJoin(baseMessagesDirPath, basename)
}))
.map(({ languageTag, filePath }) => {
const lines = fs
.readFileSync(filePath)
.toString("utf8")
.split(/\r?\n/);
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 messagesJson = "{";
let isInDeclaration = false;
let isInDeclaration = false;
for (const line of lines) {
if (!isInDeclaration) {
if (line.startsWith("const messages")) {
isInDeclaration = true;
}
continue;
for (const line of lines) {
if (!isInDeclaration) {
if (line.startsWith("const messages")) {
isInDeclaration = true;
}
if (line.startsWith("}")) {
messagesJson += "}";
break;
}
messagesJson += line;
continue;
}
const messages = JSON.parse(messagesJson) as Record<string, string>;
if (line.startsWith("}")) {
messagesJson += "}";
break;
}
return [languageTag, messages];
})
);
messagesJson += line;
}
const messages = JSON.parse(messagesJson) as Record<string, string>;
return [languageTag, messages];
})
);
const { i18nTsFilePath } = (() => {
let files = crawl({
dirPath: pathJoin(themeSrcDirPath, themeType),
dirPath: pathJoin(buildContext.themeSrcDirPath, themeType),
returnedPathsType: "absolute"
});
@ -88,7 +101,7 @@ export function generateMessageProperties(params: {
files = files.sort((a, b) => a.length - b.length);
files = files.filter(file =>
fs.readFileSync(file).toString("utf8").includes("createUseI18n(")
fs.readFileSync(file).toString("utf8").includes("i18nBuilder")
);
const i18nTsFilePath: string | undefined = files[0];
@ -96,97 +109,334 @@ export function generateMessageProperties(params: {
return { i18nTsFilePath };
})();
const messageBundle: { [languageTag: string]: Record<string, string> } | undefined =
(() => {
if (i18nTsFilePath === undefined) {
return undefined;
}
const i18nTsRoot = (() => {
if (i18nTsFilePath === undefined) {
return undefined;
}
const root = recastParseTs(i18nTsFilePath);
return root;
})();
const root = recast.parse(fs.readFileSync(i18nTsFilePath).toString("utf8"), {
parser: {
parse: (code: string) =>
babelParser.parse(code, {
sourceType: "module",
plugins: ["typescript"]
}),
generator: babelGenerate,
types: babelTypes
}
});
const messages_defaultSet_by_languageTag_notInDefaultSet:
| { [languageTag_notInDefaultSet: string]: Record<string, string> }
| undefined = (() => {
if (i18nTsRoot === undefined) {
return undefined;
}
let messageBundleDeclarationTsCode: string | undefined = undefined;
let extraLanguageEntryByLanguageTag: Record<
string,
{ label: string; path: string }
> = {};
recast.visit(root, {
visitCallExpression: function (path) {
if (
path.node.callee.type === "Identifier" &&
path.node.callee.name === "createUseI18n"
) {
messageBundleDeclarationTsCode = babelGenerate(
path.node.arguments[0] as any
).code;
return false;
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
};
}
}
}
});
}
this.traverse(path);
return false; // Stop traversing this path
}
});
assert(messageBundleDeclarationTsCode !== undefined);
let messageBundle: {
[languageTag: string]: Record<string, string>;
} = {};
try {
eval(
`${symToStr({ messageBundle })} = ${messageBundleDeclarationTsCode}`
);
} catch {
console.warn(
[
"WARNING: Make sure the messageBundle your provided as argument of createUseI18n can be statically evaluated.",
"This is important because we need to put your i18n messages in messages_*.properties files",
"or they won't be available server side.",
"\n",
"The following code could not be evaluated:",
"\n",
messageBundleDeclarationTsCode
].join(" ")
);
this.traverse(path); // Continue traversing other paths
}
});
return messageBundle;
})();
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 mergedMessageBundle: { [languageTag: string]: Record<string, string> } =
Object.fromEntries(
Object.entries(baseMessageBundle).map(([languageTag, messages]) => [
languageTag,
{
...messages,
...(messageBundle === undefined
? {}
: messageBundle[languageTag] ??
messageBundle[FALLBACK_LANGUAGE_TAG] ??
messageBundle[Object.keys(messageBundle)[0]] ??
{})
}
])
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;
})()
]
)
);
const messageProperties: { languageTag: string; propertiesFileSource: string }[] =
Object.entries(mergedMessageBundle).map(([languageTag, messages]) => ({
languageTag,
propertiesFileSource: [
"",
...(themeType !== "account" ? ["parent=base"] : []),
...Object.entries(messages).map(
([key, value]) => `${key}=${escapeStringForPropertiesFile(value)}`
),
""
].join("\n")
}));
return messages_defaultSet_by_languageTag_notInDefaultSet;
})();
return messageProperties;
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,16 +1,56 @@
import type { BuildContext } from "../../shared/buildContext";
import { assert } from "tsafe/assert";
import {
generateResourcesForMainTheme,
type BuildContextLike as BuildContextLike_generateResourcesForMainTheme
} from "./generateResourcesForMainTheme";
import { generateResourcesForThemeVariant } from "./generateResourcesForThemeVariant";
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,
extname as pathExtname,
sep as pathSep
} 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
} 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 {
writeMetaInfKeycloakThemes,
type MetaInfKeycloakTheme
} from "../../shared/metaInfKeycloakThemes";
import { objectEntries } from "tsafe/objectEntries";
import { escapeStringForPropertiesFile } from "../../tools/escapeStringForPropertiesFile";
import * as child_process from "child_process";
import { getThisCodebaseRootDirPath } from "../../tools/getThisCodebaseRootDirPath";
import propertiesParser from "properties-parser";
export type BuildContextLike = BuildContextLike_generateResourcesForMainTheme & {
themeNames: string[];
};
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>();
@ -20,23 +60,443 @@ export async function generateResources(params: {
}): Promise<void> {
const { resourcesDirPath, buildContext } = params;
const [themeName, ...themeVariantNames] = buildContext.themeNames;
const [themeName] = buildContext.themeNames;
if (fs.existsSync(resourcesDirPath)) {
rmSync(resourcesDirPath, { recursive: true });
}
await generateResourcesForMainTheme({
resourcesDirPath,
themeName,
buildContext
});
const getThemeTypeDirPath = (params: {
themeType: ThemeType | "email";
themeName: string;
}) => {
const { themeType, themeName } = params;
return pathJoin(resourcesDirPath, "theme", themeName, themeType);
};
for (const themeVariantName of themeVariantNames) {
generateResourcesForThemeVariant({
resourcesDirPath,
const writeMessagePropertiesFilesByThemeType: Partial<
Record<ThemeType, (params: { messageDirPath: string; themeName: string }) => void>
> = {};
for (const themeType of ["login", "account"] as const) {
if (!buildContext.implementedThemeTypes[themeType].isImplemented) {
continue;
}
const isForAccountSpa =
themeType === "account" &&
(assert(buildContext.implementedThemeTypes.account.isImplemented),
buildContext.implementedThemeTypes.account.type === "Single-Page");
const themeTypeDirPath = getThemeTypeDirPath({ themeName, themeType });
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 === "account" &&
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 };
}
});
}
const { generateFtlFilesCode } = generateFtlFilesCodeFactory({
themeName,
themeVariantName
indexHtmlCode: fs
.readFileSync(pathJoin(buildContext.projectBuildDirPath, "index.html"))
.toString("utf8"),
buildContext,
keycloakifyVersion: readThisNpmPackageVersion(),
themeType,
fieldNames: readFieldNameUsage({
themeSrcDirPath: buildContext.themeSrcDirPath,
themeType
})
});
[
...(() => {
switch (themeType) {
case "login":
return LOGIN_THEME_PAGE_IDS;
case "account":
return isForAccountSpa ? ["index.ftl"] : ACCOUNT_THEME_PAGE_IDS;
}
})(),
...(isForAccountSpa
? []
: readExtraPagesNames({
themeType,
themeSrcDirPath: buildContext.themeSrcDirPath
}))
].forEach(pageId => {
const { ftlCode } = generateFtlFilesCode({ pageId });
fs.writeFileSync(
pathJoin(themeTypeDirPath, pageId),
Buffer.from(ftlCode, "utf8")
);
});
let languageTags: string[] | undefined = undefined;
i18n_messages_generation: {
if (isForAccountSpa) {
break i18n_messages_generation;
}
const wrap = generateMessageProperties({
buildContext,
themeType
});
languageTags = wrap.languageTags;
const { writeMessagePropertiesFiles } = wrap;
writeMessagePropertiesFilesByThemeType[themeType] =
writeMessagePropertiesFiles;
}
bring_in_account_v3_i18n_messages: {
if (!buildContext.implementedThemeTypes.account.isImplemented) {
break bring_in_account_v3_i18n_messages;
}
if (buildContext.implementedThemeTypes.account.type !== "Single-Page") {
break bring_in_account_v3_i18n_messages;
}
const accountUiDirPath = child_process
.execSync("npm list @keycloakify/keycloak-account-ui --parseable", {
cwd: pathDirname(buildContext.packageJsonFilePath)
})
.toString("utf8")
.trim();
const messageDirPath_defaults = pathJoin(accountUiDirPath, "messages");
if (!fs.existsSync(messageDirPath_defaults)) {
throw new Error(
`Please update @keycloakify/keycloak-account-ui to 25.0.4-rc.5 or later.`
);
}
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$/, "")
);
}
keycloak_static_resources: {
if (isForAccountSpa) {
break keycloak_static_resources;
}
transformCodebase({
srcDirPath: pathJoin(
getThisCodebaseRootDirPath(),
"res",
"public",
WELL_KNOWN_DIRECTORY_BASE_NAME.KEYCLOAKIFY_DEV_RESOURCES,
themeType
),
destDirPath: pathJoin(themeTypeDirPath, "resources")
});
}
fs.writeFileSync(
pathJoin(themeTypeDirPath, "theme.properties"),
Buffer.from(
[
`parent=${(() => {
switch (themeType) {
case "account":
return isForAccountSpa ? "base" : "account-v1";
case "login":
return "keycloak";
}
assert<Equals<typeof themeType, never>>(false);
})()}`,
...(isForAccountSpa ? ["deprecatedMode=false"] : []),
...(buildContext.extraThemeProperties ?? []),
...buildContext.environmentVariables.map(
({ name, default: defaultValue }) =>
`${name}=\${env.${name}:${escapeStringForPropertiesFile(defaultValue)}}`
),
...(languageTags === undefined
? []
: [`locales=${languageTags.join(",")}`])
].join("\n\n"),
"utf8"
)
);
}
email: {
if (!buildContext.implementedThemeTypes.email.isImplemented) {
break email;
}
const emailThemeSrcDirPath = pathJoin(buildContext.themeSrcDirPath, "email");
transformCodebase({
srcDirPath: emailThemeSrcDirPath,
destDirPath: getThemeTypeDirPath({ themeName, themeType: "email" })
});
}
bring_in_account_v1: {
if (!buildContext.implementedThemeTypes.account.isImplemented) {
break bring_in_account_v1;
}
if (buildContext.implementedThemeTypes.account.type !== "Multi-Page") {
break bring_in_account_v1;
}
transformCodebase({
srcDirPath: pathJoin(getThisCodebaseRootDirPath(), "res", "account-v1"),
destDirPath: getThemeTypeDirPath({
themeName: "account-v1",
themeType: "account"
})
});
}
{
const metaInfKeycloakThemes: MetaInfKeycloakTheme = { themes: [] };
for (const themeName of buildContext.themeNames) {
metaInfKeycloakThemes.themes.push({
name: themeName,
types: objectEntries(buildContext.implementedThemeTypes)
.filter(([, { isImplemented }]) => isImplemented)
.map(([themeType]) => themeType)
});
}
if (buildContext.implementedThemeTypes.account.isImplemented) {
metaInfKeycloakThemes.themes.push({
name: "account-v1",
types: ["account"]
});
}
writeMetaInfKeycloakThemes({
resourcesDirPath,
getNewMetaInfKeycloakTheme: () => metaInfKeycloakThemes
});
}
for (const themeVariantName of buildContext.themeNames) {
if (themeVariantName === themeName) {
continue;
}
transformCodebase({
srcDirPath: pathJoin(resourcesDirPath, "theme", themeName),
destDirPath: pathJoin(resourcesDirPath, "theme", themeVariantName),
transformSourceCode: ({ fileRelativePath, sourceCode }) => {
if (
pathExtname(fileRelativePath) === ".ftl" &&
fileRelativePath.split(pathSep).length === 2
) {
const modifiedSourceCode = Buffer.from(
Buffer.from(sourceCode)
.toString("utf-8")
.replace(
`"themeName": "${themeName}"`,
`"themeName": "${themeVariantName}"`
),
"utf8"
);
assert(Buffer.compare(modifiedSourceCode, sourceCode) !== 0);
return { modifiedSourceCode };
}
return { modifiedSourceCode: sourceCode };
}
});
}
for (const themeName of buildContext.themeNames) {
for (const [themeType, writeMessagePropertiesFiles] of objectEntries(
writeMessagePropertiesFilesByThemeType
)) {
// NOTE: This is just a quirk of the type system: We can't really differentiate in a record
// between the case where the key isn't present and the case where the value is `undefined`.
if (writeMessagePropertiesFiles === undefined) {
return;
}
writeMessagePropertiesFiles({
messageDirPath: pathJoin(
getThemeTypeDirPath({ themeName, themeType }),
"messages"
),
themeName
});
}
}
modify_email_theme_per_variant: {
if (!buildContext.implementedThemeTypes.email.isImplemented) {
break modify_email_theme_per_variant;
}
for (const themeName of buildContext.themeNames) {
const emailThemeDirPath = getThemeTypeDirPath({
themeName,
themeType: "email"
});
transformCodebase({
srcDirPath: emailThemeDirPath,
destDirPath: emailThemeDirPath,
transformSourceCode: ({ filePath, sourceCode }) => {
if (!filePath.endsWith(".ftl")) {
return { modifiedSourceCode: sourceCode };
}
return {
modifiedSourceCode: Buffer.from(
sourceCode
.toString("utf8")
.replace(/xKeycloakify\.themeName/g, `"${themeName}"`),
"utf8"
)
};
}
});
}
}
}

View File

@ -1,366 +0,0 @@
import { transformCodebase } from "../../tools/transformCodebase";
import * as fs from "fs";
import {
join as pathJoin,
resolve as pathResolve,
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,
LAST_KEYCLOAK_VERSION_WITH_ACCOUNT_V1,
KEYCLOAK_RESOURCES,
ACCOUNT_V1_THEME_NAME,
BASENAME_OF_KEYCLOAKIFY_RESOURCES_DIR,
LOGIN_THEME_PAGE_IDS,
ACCOUNT_THEME_PAGE_IDS
} from "../../shared/constants";
import type { BuildContext } from "../../shared/buildContext";
import { assert, type Equals } from "tsafe/assert";
import {
downloadKeycloakStaticResources,
type BuildContextLike as BuildContextLike_downloadKeycloakStaticResources
} from "../../shared/downloadKeycloakStaticResources";
import { readFieldNameUsage } from "./readFieldNameUsage";
import { readExtraPagesNames } from "./readExtraPageNames";
import { generateMessageProperties } from "./generateMessageProperties";
import {
bringInAccountV1,
type BuildContextLike as BuildContextLike_bringInAccountV1
} from "./bringInAccountV1";
import { rmSync } from "../../tools/fs.rmSync";
import { readThisNpmPackageVersion } from "../../tools/readThisNpmPackageVersion";
import {
writeMetaInfKeycloakThemes,
type MetaInfKeycloakTheme
} from "../../shared/metaInfKeycloakThemes";
import { objectEntries } from "tsafe/objectEntries";
import { escapeStringForPropertiesFile } from "../../tools/escapeStringForPropertiesFile";
import { downloadAndExtractArchive } from "../../tools/downloadAndExtractArchive";
export type BuildContextLike = BuildContextLike_kcContextExclusionsFtlCode &
BuildContextLike_downloadKeycloakStaticResources &
BuildContextLike_bringInAccountV1 & {
extraThemeProperties: string[] | undefined;
loginThemeResourcesFromKeycloakVersion: string;
projectDirPath: string;
projectBuildDirPath: string;
environmentVariables: { name: string; default: string }[];
implementedThemeTypes: BuildContext["implementedThemeTypes"];
themeSrcDirPath: string;
bundler: "vite" | "webpack";
};
assert<BuildContext extends BuildContextLike ? true : false>();
export async function generateResourcesForMainTheme(params: {
themeName: string;
resourcesDirPath: string;
buildContext: BuildContextLike;
}): Promise<void> {
const { themeName, resourcesDirPath, buildContext } = params;
const getThemeTypeDirPath = (params: { themeType: ThemeType | "email" }) => {
const { themeType } = params;
return pathJoin(resourcesDirPath, "theme", themeName, themeType);
};
for (const themeType of ["login", "account"] as const) {
if (!buildContext.implementedThemeTypes[themeType].isImplemented) {
continue;
}
const isForAccountSpa =
themeType === "account" &&
(assert(buildContext.implementedThemeTypes.account.isImplemented),
buildContext.implementedThemeTypes.account.type === "Single-Page");
const themeTypeDirPath = getThemeTypeDirPath({ themeType });
apply_replacers_and_move_to_theme_resources: {
const destDirPath = pathJoin(
themeTypeDirPath,
"resources",
BASENAME_OF_KEYCLOAKIFY_RESOURCES_DIR
);
// NOTE: Prevent accumulation of files in the assets dir, as names are hashed they pile up.
rmSync(destDirPath, { recursive: true, force: true });
if (
themeType === "account" &&
buildContext.implementedThemeTypes.login.isImplemented
) {
// NOTE: We prevent doing it twice, it has been done for the login theme.
transformCodebase({
srcDirPath: pathJoin(
getThemeTypeDirPath({
themeType: "login"
}),
"resources",
BASENAME_OF_KEYCLOAKIFY_RESOURCES_DIR
),
destDirPath
});
break apply_replacers_and_move_to_theme_resources;
}
{
const dirPath = pathJoin(
buildContext.projectBuildDirPath,
KEYCLOAK_RESOURCES
);
if (fs.existsSync(dirPath)) {
assert(buildContext.bundler === "webpack");
throw new Error(
[
`Keycloakify build error: The ${KEYCLOAK_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 };
}
});
}
const { generateFtlFilesCode } = generateFtlFilesCodeFactory({
themeName,
indexHtmlCode: fs
.readFileSync(pathJoin(buildContext.projectBuildDirPath, "index.html"))
.toString("utf8"),
buildContext,
keycloakifyVersion: readThisNpmPackageVersion(),
themeType,
fieldNames: readFieldNameUsage({
themeSrcDirPath: buildContext.themeSrcDirPath,
themeType
})
});
[
...(() => {
switch (themeType) {
case "login":
return LOGIN_THEME_PAGE_IDS;
case "account":
return isForAccountSpa ? ["index.ftl"] : ACCOUNT_THEME_PAGE_IDS;
}
})(),
...(isForAccountSpa
? []
: readExtraPagesNames({
themeType,
themeSrcDirPath: buildContext.themeSrcDirPath
}))
].forEach(pageId => {
const { ftlCode } = generateFtlFilesCode({ pageId });
fs.writeFileSync(
pathJoin(themeTypeDirPath, pageId),
Buffer.from(ftlCode, "utf8")
);
});
i18n_messages_generation: {
if (isForAccountSpa) {
break i18n_messages_generation;
}
generateMessageProperties({
themeSrcDirPath: buildContext.themeSrcDirPath,
themeType
}).forEach(({ languageTag, propertiesFileSource }) => {
const messagesDirPath = pathJoin(themeTypeDirPath, "messages");
fs.mkdirSync(pathJoin(themeTypeDirPath, "messages"), {
recursive: true
});
const propertiesFilePath = pathJoin(
messagesDirPath,
`messages_${languageTag}.properties`
);
fs.writeFileSync(
propertiesFilePath,
Buffer.from(propertiesFileSource, "utf8")
);
});
}
keycloak_static_resources: {
if (isForAccountSpa) {
break keycloak_static_resources;
}
await downloadKeycloakStaticResources({
keycloakVersion: (() => {
switch (themeType) {
case "account":
return LAST_KEYCLOAK_VERSION_WITH_ACCOUNT_V1;
case "login":
return buildContext.loginThemeResourcesFromKeycloakVersion;
}
})(),
themeDirPath: pathResolve(pathJoin(themeTypeDirPath, "..")),
themeType,
buildContext
});
}
fs.writeFileSync(
pathJoin(themeTypeDirPath, "theme.properties"),
Buffer.from(
[
`parent=${(() => {
switch (themeType) {
case "account":
return isForAccountSpa ? "base" : ACCOUNT_V1_THEME_NAME;
case "login":
return "keycloak";
}
assert<Equals<typeof themeType, never>>(false);
})()}`,
...(isForAccountSpa ? ["deprecatedMode=false"] : []),
...(buildContext.extraThemeProperties ?? []),
...buildContext.environmentVariables.map(
({ name, default: defaultValue }) =>
`${name}=\${env.${name}:${escapeStringForPropertiesFile(defaultValue)}}`
)
].join("\n\n"),
"utf8"
)
);
}
email: {
if (!buildContext.implementedThemeTypes.email.isImplemented) {
break email;
}
const emailThemeSrcDirPath = pathJoin(buildContext.themeSrcDirPath, "email");
transformCodebase({
srcDirPath: emailThemeSrcDirPath,
destDirPath: getThemeTypeDirPath({ themeType: "email" })
});
}
bring_in_account_v1: {
if (!buildContext.implementedThemeTypes.account.isImplemented) {
break bring_in_account_v1;
}
if (buildContext.implementedThemeTypes.account.type !== "Multi-Page") {
break bring_in_account_v1;
}
await bringInAccountV1({
resourcesDirPath,
buildContext
});
}
bring_in_account_v3_i18n_messages: {
if (!buildContext.implementedThemeTypes.account.isImplemented) {
break bring_in_account_v3_i18n_messages;
}
if (buildContext.implementedThemeTypes.account.type !== "Single-Page") {
break bring_in_account_v3_i18n_messages;
}
const { extractedDirPath } = await downloadAndExtractArchive({
url: "https://repo1.maven.org/maven2/org/keycloak/keycloak-account-ui/25.0.1/keycloak-account-ui-25.0.1.jar",
cacheDirPath: buildContext.cacheDirPath,
fetchOptions: buildContext.fetchOptions,
uniqueIdOfOnArchiveFile: "bring_in_account_v3_i18n_messages",
onArchiveFile: async ({ fileRelativePath, writeFile }) => {
if (
!fileRelativePath.startsWith(
pathJoin("theme", "keycloak.v3", "account", "messages")
)
) {
return;
}
await writeFile({
fileRelativePath: pathBasename(fileRelativePath)
});
}
});
transformCodebase({
srcDirPath: extractedDirPath,
destDirPath: pathJoin(
getThemeTypeDirPath({ themeType: "account" }),
"messages"
)
});
}
{
const metaInfKeycloakThemes: MetaInfKeycloakTheme = { themes: [] };
metaInfKeycloakThemes.themes.push({
name: themeName,
types: objectEntries(buildContext.implementedThemeTypes)
.filter(([, { isImplemented }]) => isImplemented)
.map(([themeType]) => themeType)
});
if (buildContext.implementedThemeTypes.account.isImplemented) {
metaInfKeycloakThemes.themes.push({
name: ACCOUNT_V1_THEME_NAME,
types: ["account"]
});
}
writeMetaInfKeycloakThemes({
resourcesDirPath,
getNewMetaInfKeycloakTheme: () => metaInfKeycloakThemes
});
}
}

View File

@ -1,70 +0,0 @@
import { join as pathJoin, extname as pathExtname, sep as pathSep } from "path";
import { transformCodebase } from "../../tools/transformCodebase";
import type { BuildContext } from "../../shared/buildContext";
import { writeMetaInfKeycloakThemes } from "../../shared/metaInfKeycloakThemes";
import { assert } from "tsafe/assert";
export type BuildContextLike = {
keycloakifyBuildDirPath: string;
};
assert<BuildContext extends BuildContextLike ? true : false>();
export function generateResourcesForThemeVariant(params: {
resourcesDirPath: string;
themeName: string;
themeVariantName: string;
}) {
const { resourcesDirPath, themeName, themeVariantName } = params;
const mainThemeDirPath = pathJoin(resourcesDirPath, "theme", themeName);
transformCodebase({
srcDirPath: mainThemeDirPath,
destDirPath: pathJoin(mainThemeDirPath, "..", themeVariantName),
transformSourceCode: ({ fileRelativePath, sourceCode }) => {
if (
pathExtname(fileRelativePath) === ".ftl" &&
fileRelativePath.split(pathSep).length === 2
) {
const modifiedSourceCode = Buffer.from(
Buffer.from(sourceCode)
.toString("utf-8")
.replace(
`"themeName": "${themeName}"`,
`"themeName": "${themeVariantName}"`
),
"utf8"
);
assert(Buffer.compare(modifiedSourceCode, sourceCode) !== 0);
return { modifiedSourceCode };
}
return { modifiedSourceCode: sourceCode };
}
});
writeMetaInfKeycloakThemes({
resourcesDirPath,
getNewMetaInfKeycloakTheme: ({ metaInfKeycloakTheme }) => {
assert(metaInfKeycloakTheme !== undefined);
const newMetaInfKeycloakTheme = metaInfKeycloakTheme;
newMetaInfKeycloakTheme.themes.push({
name: themeVariantName,
types: (() => {
const theme = newMetaInfKeycloakTheme.themes.find(
({ name }) => name === themeName
);
assert(theme !== undefined);
return theme.types;
})()
});
return newMetaInfKeycloakTheme;
}
});
}

View File

@ -2,16 +2,17 @@ import { generateResources } from "./generateResources";
import { join as pathJoin, relative as pathRelative, sep as pathSep } from "path";
import * as child_process from "child_process";
import * as fs from "fs";
import { getBuildContext } from "../shared/buildContext";
import type { BuildContext } from "../shared/buildContext";
import { VITE_PLUGIN_SUB_SCRIPTS_ENV_NAMES } from "../shared/constants";
import { buildJars } from "./buildJars";
import type { CliCommandOptions } from "../main";
import chalk from "chalk";
import { readThisNpmPackageVersion } from "../tools/readThisNpmPackageVersion";
import * as os from "os";
import { rmSync } from "../tools/fs.rmSync";
export async function command(params: { cliCommandOptions: CliCommandOptions }) {
export async function command(params: { buildContext: BuildContext }) {
const { buildContext } = params;
exit_if_maven_not_installed: {
let commandOutput: Buffer | undefined = undefined;
@ -25,31 +26,44 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
break exit_if_maven_not_installed;
}
const installationCommand = (() => {
switch (os.platform()) {
case "darwin":
return "brew install mvn";
case "win32":
return "choco install mvn";
case "linux":
default:
return "sudo apt-get install mvn";
}
})();
if (
fs
.readFileSync(buildContext.packageJsonFilePath)
.toString("utf8")
.includes(`"mvn"`)
) {
console.log(
chalk.red(
[
"Please remove the 'mvn' package from your package.json'dependencies list,",
"reinstall your dependencies and try again.",
"We need the Apache Maven CLI, not this: https://www.npmjs.com/package/mvn"
].join(" ")
)
);
} else {
const installationCommand = (() => {
switch (os.platform()) {
case "darwin":
return "brew install mvn";
case "win32":
return "choco install mvn";
case "linux":
default:
return "sudo apt-get install mvn";
}
})();
console.log(
`${chalk.red("Apache Maven required.")} Install it with \`${chalk.bold(
installationCommand
)}\` (for example)`
);
console.log(
`${chalk.red("Apache Maven required.")} Install it with \`${chalk.bold(
installationCommand
)}\` (for example)`
);
}
process.exit(1);
}
const { cliCommandOptions } = params;
const buildContext = getBuildContext({ cliCommandOptions });
console.log(
[
chalk.cyan(`keycloakify v${readThisNpmPackageVersion()}`),

View File

@ -1,5 +1,5 @@
import type { BuildContext } from "../../shared/buildContext";
import { BASENAME_OF_KEYCLOAKIFY_RESOURCES_DIR } from "../../shared/constants";
import { WELL_KNOWN_DIRECTORY_BASE_NAME } from "../../shared/constants";
import { assert } from "tsafe/assert";
import { posix } from "path";
@ -50,7 +50,7 @@ export function replaceImportsInCssCode(params: {
break inline_style_in_html;
}
return `url("\${xKeycloakify.resourcesPath}/${BASENAME_OF_KEYCLOAKIFY_RESOURCES_DIR}${assetFileAbsoluteUrlPathname}")`;
return `url("\${xKeycloakify.resourcesPath}/${WELL_KNOWN_DIRECTORY_BASE_NAME.DIST}${assetFileAbsoluteUrlPathname}")`;
}
const assetFileRelativeUrlPathname = posix.relative(

View File

@ -1,4 +1,4 @@
import { BASENAME_OF_KEYCLOAKIFY_RESOURCES_DIR } from "../../../shared/constants";
import { WELL_KNOWN_DIRECTORY_BASE_NAME } from "../../../shared/constants";
import { assert } from "tsafe/assert";
import type { BuildContext } from "../../../shared/buildContext";
import * as nodePath from "path";
@ -85,13 +85,13 @@ export function replaceImportsInJsCode_vite(params: {
fixedJsCode = replaceAll(
fixedJsCode,
`"${relativePathOfAssetFile}"`,
`(window.kcContext["x-keycloakify"].resourcesPath.substring(1) + "/${BASENAME_OF_KEYCLOAKIFY_RESOURCES_DIR}/${relativePathOfAssetFile}")`
`(window.kcContext["x-keycloakify"].resourcesPath.substring(1) + "/${WELL_KNOWN_DIRECTORY_BASE_NAME.DIST}/${relativePathOfAssetFile}")`
);
fixedJsCode = replaceAll(
fixedJsCode,
`"${buildContext.urlPathname ?? "/"}${relativePathOfAssetFile}"`,
`(window.kcContext["x-keycloakify"].resourcesPath + "/${BASENAME_OF_KEYCLOAKIFY_RESOURCES_DIR}/${relativePathOfAssetFile}")`
`(window.kcContext["x-keycloakify"].resourcesPath + "/${WELL_KNOWN_DIRECTORY_BASE_NAME.DIST}/${relativePathOfAssetFile}")`
);
});
}

View File

@ -1,4 +1,4 @@
import { BASENAME_OF_KEYCLOAKIFY_RESOURCES_DIR } from "../../../shared/constants";
import { WELL_KNOWN_DIRECTORY_BASE_NAME } from "../../../shared/constants";
import { assert } from "tsafe/assert";
import type { BuildContext } from "../../../shared/buildContext";
import * as nodePath from "path";
@ -90,7 +90,7 @@ export function replaceImportsInJsCode_webpack(params: {
return "${u}";
})()] = ${
isArrowFunction ? `${e} =>` : `function(${e}) { return `
} "/${BASENAME_OF_KEYCLOAKIFY_RESOURCES_DIR}/${staticDir}${language}/"`
} "/${WELL_KNOWN_DIRECTORY_BASE_NAME.DIST}/${staticDir}${language}/"`
.replace(/\s+/g, " ")
.trim();
}
@ -104,7 +104,7 @@ export function replaceImportsInJsCode_webpack(params: {
`[a-zA-Z]+\\.[a-zA-Z]+\\+"${staticDir.replace(/\//g, "\\/")}`,
"g"
),
`window.kcContext["x-keycloakify"].resourcesPath + "/${BASENAME_OF_KEYCLOAKIFY_RESOURCES_DIR}/${staticDir}`
`window.kcContext["x-keycloakify"].resourcesPath + "/${WELL_KNOWN_DIRECTORY_BASE_NAME.DIST}/${staticDir}`
);
return { fixedJsCode };

View File

@ -4,8 +4,9 @@ import { termost } from "termost";
import { readThisNpmPackageVersion } from "./tools/readThisNpmPackageVersion";
import * as child_process from "child_process";
import { assertNoPnpmDlx } from "./tools/assertNoPnpmDlx";
import { getBuildContext } from "./shared/buildContext";
export type CliCommandOptions = {
type CliCommandOptions = {
projectDirPath: string | undefined;
};
@ -69,10 +70,10 @@ program
})
.task({
skip,
handler: async cliCommandOptions => {
handler: async ({ projectDirPath }) => {
const { command } = await import("./keycloakify");
await command({ cliCommandOptions });
await command({ buildContext: getBuildContext({ projectDirPath }) });
}
});
@ -130,10 +131,13 @@ program
})
.task({
skip,
handler: async cliCommandOptions => {
handler: async ({ projectDirPath, keycloakVersion, port, realmJsonFilePath }) => {
const { command } = await import("./start-keycloak");
await command({ cliCommandOptions });
await command({
buildContext: getBuildContext({ projectDirPath }),
cliCommandOptions: { keycloakVersion, port, realmJsonFilePath }
});
}
});
@ -144,10 +148,10 @@ program
})
.task({
skip,
handler: async cliCommandOptions => {
handler: async ({ projectDirPath }) => {
const { command } = await import("./eject-page");
await command({ cliCommandOptions });
await command({ buildContext: getBuildContext({ projectDirPath }) });
}
});
@ -158,24 +162,24 @@ program
})
.task({
skip,
handler: async cliCommandOptions => {
handler: async ({ projectDirPath }) => {
const { command } = await import("./add-story");
await command({ cliCommandOptions });
await command({ buildContext: getBuildContext({ projectDirPath }) });
}
});
program
.command({
name: "initialize-email-theme",
name: "initialize-login-theme",
description: "Initialize an email theme."
})
.task({
skip,
handler: async cliCommandOptions => {
handler: async ({ projectDirPath }) => {
const { command } = await import("./initialize-email-theme");
await command({ cliCommandOptions });
await command({ buildContext: getBuildContext({ projectDirPath }) });
}
});
@ -186,10 +190,10 @@ program
})
.task({
skip,
handler: async cliCommandOptions => {
handler: async ({ projectDirPath }) => {
const { command } = await import("./initialize-account-theme");
await command({ cliCommandOptions });
await command({ buildContext: getBuildContext({ projectDirPath }) });
}
});
@ -201,10 +205,10 @@ program
})
.task({
skip,
handler: async cliCommandOptions => {
handler: async ({ projectDirPath }) => {
const { command } = await import("./copy-keycloak-resources-to-public");
await command({ cliCommandOptions });
await command({ buildContext: getBuildContext({ projectDirPath }) });
}
});
@ -216,10 +220,10 @@ program
})
.task({
skip,
handler: async cliCommandOptions => {
handler: async ({ projectDirPath }) => {
const { command } = await import("./update-kc-gen");
await command({ cliCommandOptions });
await command({ buildContext: getBuildContext({ projectDirPath }) });
}
});

View File

@ -3,7 +3,7 @@ export type KeycloakVersionRange =
| KeycloakVersionRange.WithoutAccountV1Theme;
export namespace KeycloakVersionRange {
export type WithoutAccountV1Theme = "21-and-below" | "22-and-above";
export type WithoutAccountV1Theme = "22-to-25" | "all-other-versions";
export type WithAccountV1Theme = "21-and-below" | "23" | "24" | "25-and-above";
export type WithAccountV1Theme = "21-and-below" | "23" | "24" | "25" | "26-and-above";
}

View File

@ -7,15 +7,13 @@ import {
dirname as pathDirname
} from "path";
import { getAbsoluteAndInOsFormatPath } from "../tools/getAbsoluteAndInOsFormatPath";
import type { CliCommandOptions } from "../main";
import { z } from "zod";
import * as fs from "fs";
import { assert, type Equals } from "tsafe/assert";
import * as child_process from "child_process";
import {
VITE_PLUGIN_SUB_SCRIPTS_ENV_NAMES,
BUILD_FOR_KEYCLOAK_MAJOR_VERSION_ENV_NAME,
LOGIN_THEME_RESOURCES_FROMkEYCLOAK_VERSION_DEFAULT
BUILD_FOR_KEYCLOAK_MAJOR_VERSION_ENV_NAME
} from "./constants";
import type { KeycloakVersionRange } from "./KeycloakVersionRange";
import { exclude } from "tsafe";
@ -25,7 +23,8 @@ import { objectEntries } from "tsafe/objectEntries";
import { type ThemeType } from "./constants";
import { id } from "tsafe/id";
import chalk from "chalk";
import { getProxyFetchOptions, type ProxyFetchOptions } from "../tools/fetchProxyOptions";
import { getProxyFetchOptions, type FetchOptionsLike } from "../tools/fetchProxyOptions";
import { is } from "tsafe/is";
export type BuildContext = {
themeVersion: string;
@ -33,7 +32,6 @@ export type BuildContext = {
extraThemeProperties: string[] | undefined;
groupId: string;
artifactId: string;
loginThemeResourcesFromKeycloakVersion: string;
projectDirPath: string;
projectBuildDirPath: string;
/** Directory that keycloakify outputs to. Defaults to {cwd}/build_keycloak */
@ -44,7 +42,7 @@ export type BuildContext = {
* In this case the urlPathname will be "/my-app/" */
urlPathname: string | undefined;
assetsDirPath: string;
fetchOptions: ProxyFetchOptions;
fetchOptions: FetchOptionsLike;
kcContextExclusionsFtlCode: string | undefined;
environmentVariables: { name: string; default: string }[];
themeSrcDirPath: string;
@ -85,7 +83,6 @@ export type BuildOptions = {
extraThemeProperties?: string[];
artifactId?: string;
groupId?: string;
loginThemeResourcesFromKeycloakVersion?: string;
keycloakifyBuildDirPath?: string;
kcContextExclusionsFtl?: string;
startKeycloakOptions?: {
@ -131,14 +128,12 @@ export type ResolvedViteConfig = {
};
export function getBuildContext(params: {
cliCommandOptions: CliCommandOptions;
projectDirPath: string | undefined;
}): BuildContext {
const { cliCommandOptions } = params;
const projectDirPath =
cliCommandOptions.projectDirPath !== undefined
params.projectDirPath !== undefined
? getAbsoluteAndInOsFormatPath({
pathIsh: cliCommandOptions.projectDirPath,
pathIsh: params.projectDirPath,
cwd: process.cwd()
})
: process.cwd();
@ -243,8 +238,7 @@ export function getBuildContext(params: {
if (
parsedPackageJson.dependencies?.keycloakify === undefined &&
parsedPackageJson.devDependencies?.keycloakify === undefined &&
parsedPackageJson.name !== "keycloakify" // NOTE: For local storybook build
parsedPackageJson.devDependencies?.keycloakify === undefined
) {
break success;
}
@ -280,7 +274,8 @@ export function getBuildContext(params: {
"21-and-below": z.union([z.boolean(), z.string()]),
"23": z.union([z.boolean(), z.string()]),
"24": z.union([z.boolean(), z.string()]),
"25-and-above": z.union([z.boolean(), z.string()])
"25": z.union([z.boolean(), z.string()]),
"26-and-above": z.union([z.boolean(), z.string()])
})
.optional()
});
@ -301,8 +296,8 @@ export function getBuildContext(params: {
]),
keycloakVersionTargets: z
.object({
"21-and-below": z.union([z.boolean(), z.string()]),
"22-and-above": z.union([z.boolean(), z.string()])
"22-to-25": z.union([z.boolean(), z.string()]),
"all-other-versions": z.union([z.boolean(), z.string()])
})
.optional()
});
@ -357,7 +352,6 @@ export function getBuildContext(params: {
extraThemeProperties: z.array(z.string()).optional(),
artifactId: z.string().optional(),
groupId: z.string().optional(),
loginThemeResourcesFromKeycloakVersion: z.string().optional(),
keycloakifyBuildDirPath: z.string().optional(),
kcContextExclusionsFtl: z.string().optional(),
startKeycloakOptions: zStartKeycloakOptions.optional()
@ -474,26 +468,44 @@ export function getBuildContext(params: {
}
const themeNames = ((): [string, ...string[]] => {
if (buildOptions.themeName === undefined) {
return parsedPackageJson.name === undefined
? ["keycloakify"]
: [
parsedPackageJson.name
.replace(/^@(.*)/, "$1")
.split("/")
.join("-")
];
const themeNames = ((): [string, ...string[]] => {
if (buildOptions.themeName === undefined) {
return parsedPackageJson.name === undefined
? ["keycloakify"]
: [
parsedPackageJson.name
.replace(/^@(.*)/, "$1")
.split("/")
.join("-")
];
}
if (typeof buildOptions.themeName === "string") {
return [buildOptions.themeName];
}
const [mainThemeName, ...themeVariantNames] = buildOptions.themeName;
assert(mainThemeName !== undefined);
return [mainThemeName, ...themeVariantNames];
})();
for (const themeName of themeNames) {
if (!/^[a-zA-Z0-9_-]+$/.test(themeName)) {
console.error(
chalk.red(
[
`Invalid theme name: ${themeName}`,
`Theme names should only contain letters, numbers, and "_" or "-"`
].join(" ")
)
);
process.exit(-1);
}
}
if (typeof buildOptions.themeName === "string") {
return [buildOptions.themeName];
}
const [mainThemeName, ...themeVariantNames] = buildOptions.themeName;
assert(mainThemeName !== undefined);
return [mainThemeName, ...themeVariantNames];
return themeNames;
})();
const projectBuildDirPath = (() => {
@ -545,9 +557,6 @@ export function getBuildContext(params: {
process.env.KEYCLOAKIFY_ARTIFACT_ID ??
buildOptions.artifactId ??
`${themeNames[0]}-keycloak-theme`,
loginThemeResourcesFromKeycloakVersion:
buildOptions.loginThemeResourcesFromKeycloakVersion ??
LOGIN_THEME_RESOURCES_FROMkEYCLOAK_VERSION_DEFAULT,
projectDirPath,
projectBuildDirPath,
keycloakifyBuildDirPath: (() => {
@ -756,7 +765,11 @@ export function getBuildContext(params: {
return "24" as const;
}
return "25-and-above" as const;
if (buildForKeycloakMajorVersionNumber === 25) {
return "25" as const;
}
return "26-and-above" as const;
})();
assert<
@ -769,11 +782,14 @@ export function getBuildContext(params: {
return keycloakVersionRange;
} else {
const keycloakVersionRange = (() => {
if (buildForKeycloakMajorVersionNumber <= 21) {
return "21-and-below" as const;
if (
buildForKeycloakMajorVersionNumber <= 21 ||
buildForKeycloakMajorVersionNumber >= 26
) {
return "all-other-versions" as const;
}
return "22-and-above" as const;
return "22-to-25" as const;
})();
assert<
@ -791,6 +807,12 @@ export function getBuildContext(params: {
use_custom_jar_basename: {
const { keycloakVersionTargets } = buildOptions;
assert(
is<Record<KeycloakVersionRange, string | boolean>>(
keycloakVersionTargets
)
);
if (keycloakVersionTargets === undefined) {
break use_custom_jar_basename;
}
@ -835,7 +857,8 @@ export function getBuildContext(params: {
"21-and-below",
"23",
"24",
"25-and-above"
"25",
"26-and-above"
] as const) {
assert<
Equals<
@ -851,8 +874,8 @@ export function getBuildContext(params: {
}
} else {
for (const keycloakVersionRange of [
"21-and-below",
"22-and-above"
"22-to-25",
"all-other-versions"
] as const) {
assert<
Equals<
@ -878,7 +901,17 @@ export function getBuildContext(params: {
const jarTargets: BuildContext["jarTargets"] = [];
for (const [keycloakVersionRange, jarNameOrBoolean] of objectEntries(
buildOptions.keycloakVersionTargets
(() => {
const { keycloakVersionTargets } = buildOptions;
assert(
is<Record<KeycloakVersionRange, string | boolean>>(
keycloakVersionTargets
)
);
return keycloakVersionTargets;
})()
)) {
if (jarNameOrBoolean === false) {
continue;

View File

@ -1,10 +1,10 @@
export const KEYCLOAK_RESOURCES = "keycloak-resources";
export const RESOURCES_COMMON = "resources-common";
export const LAST_KEYCLOAK_VERSION_WITH_ACCOUNT_V1 = "21.1.2";
export const BASENAME_OF_KEYCLOAKIFY_RESOURCES_DIR = "dist";
export const WELL_KNOWN_DIRECTORY_BASE_NAME = {
KEYCLOAKIFY_DEV_RESOURCES: "keycloakify-dev-resources",
RESOURCES_COMMON: "resources-common",
DIST: "dist"
} as const;
export const THEME_TYPES = ["login", "account"] as const;
export const ACCOUNT_V1_THEME_NAME = "account-v1";
export type ThemeType = (typeof THEME_TYPES)[number];
@ -50,7 +50,9 @@ export const LOGIN_THEME_PAGE_IDS = [
"login-recovery-authn-code-input.ftl",
"login-reset-otp.ftl",
"login-x509-info.ftl",
"webauthn-error.ftl"
"webauthn-error.ftl",
"login-passkeys-conditional-authenticate.ftl",
"login-idp-link-confirm-override.ftl"
] as const;
export const ACCOUNT_THEME_PAGE_IDS = [
@ -70,4 +72,7 @@ export const CONTAINER_NAME = "keycloak-keycloakify";
export const FALLBACK_LANGUAGE_TAG = "en";
export const LOGIN_THEME_RESOURCES_FROMkEYCLOAK_VERSION_DEFAULT = "24.0.4";
export const CUSTOM_HANDLER_ENV_NAMES = {
COMMAND_NAME: "KEYCLOAKIFY_COMMAND_NAME",
BUILD_CONTEXT: "KEYCLOAKIFY_BUILD_CONTEXT"
};

View File

@ -1,44 +1,34 @@
import {
downloadKeycloakStaticResources,
type BuildContextLike as BuildContextLike_downloadKeycloakStaticResources
} from "./downloadKeycloakStaticResources";
import { join as pathJoin, relative as pathRelative } from "path";
import {
THEME_TYPES,
KEYCLOAK_RESOURCES,
LAST_KEYCLOAK_VERSION_WITH_ACCOUNT_V1
} from "../shared/constants";
import { join as pathJoin, dirname as pathDirname } from "path";
import { WELL_KNOWN_DIRECTORY_BASE_NAME } from "../shared/constants";
import { readThisNpmPackageVersion } from "../tools/readThisNpmPackageVersion";
import { assert } from "tsafe/assert";
import * as fs from "fs";
import { rmSync } from "../tools/fs.rmSync";
import type { BuildContext } from "./buildContext";
import { transformCodebase } from "../tools/transformCodebase";
import { getThisCodebaseRootDirPath } from "../tools/getThisCodebaseRootDirPath";
export type BuildContextLike = BuildContextLike_downloadKeycloakStaticResources & {
loginThemeResourcesFromKeycloakVersion: string;
export type BuildContextLike = {
publicDirPath: string;
};
assert<BuildContext extends BuildContextLike ? true : false>();
export async function copyKeycloakResourcesToPublic(params: {
export function copyKeycloakResourcesToPublic(params: {
buildContext: BuildContextLike;
}) {
const { buildContext } = params;
const destDirPath = pathJoin(buildContext.publicDirPath, KEYCLOAK_RESOURCES);
const destDirPath = pathJoin(
buildContext.publicDirPath,
WELL_KNOWN_DIRECTORY_BASE_NAME.KEYCLOAKIFY_DEV_RESOURCES
);
const keycloakifyBuildinfoFilePath = pathJoin(destDirPath, "keycloakify.buildinfo");
const keycloakifyBuildinfoRaw = JSON.stringify(
{
destDirPath,
keycloakifyVersion: readThisNpmPackageVersion(),
buildContext: {
loginThemeResourcesFromKeycloakVersion: readThisNpmPackageVersion(),
cacheDirPath: pathRelative(destDirPath, buildContext.cacheDirPath),
fetchOptions: buildContext.fetchOptions
}
keycloakifyVersion: readThisNpmPackageVersion()
},
null,
2
@ -62,35 +52,39 @@ export async function copyKeycloakResourcesToPublic(params: {
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"));
for (const themeType of THEME_TYPES) {
await downloadKeycloakStaticResources({
keycloakVersion: (() => {
switch (themeType) {
case "login":
return buildContext.loginThemeResourcesFromKeycloakVersion;
case "account":
return LAST_KEYCLOAK_VERSION_WITH_ACCOUNT_V1;
}
})(),
themeType,
themeDirPath: destDirPath,
buildContext
});
}
transformCodebase({
srcDirPath: pathJoin(
getThisCodebaseRootDirPath(),
"res",
"public",
WELL_KNOWN_DIRECTORY_BASE_NAME.KEYCLOAKIFY_DEV_RESOURCES
),
destDirPath
});
fs.writeFileSync(
pathJoin(destDirPath, "README.txt"),
Buffer.from(
// prettier-ignore
[
"This is just a test folder that helps develop",
"the login and register page without having to run a Keycloak container\n",
"This directory will be automatically excluded from the final build."
].join(" ")
"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")
)
);

View File

@ -0,0 +1,41 @@
import { assert } from "tsafe/assert";
import type { BuildContext } from "./buildContext";
import { CUSTOM_HANDLER_ENV_NAMES } from "./constants";
export const BIN_NAME = "_keycloakify-custom-handler";
export const NOT_IMPLEMENTED_EXIT_CODE = 78;
export type CommandName =
| "update-kc-gen"
| "eject-page"
| "add-story"
| "initialize-account-theme"
| "initialize-email-theme"
| "copy-keycloak-resources-to-public";
export type ApiVersion = "v1";
export function readParams(params: { apiVersion: ApiVersion }) {
const { apiVersion } = params;
assert(apiVersion === "v1");
const commandName = (() => {
const envValue = process.env[CUSTOM_HANDLER_ENV_NAMES.COMMAND_NAME];
assert(envValue !== undefined);
return envValue as CommandName;
})();
const buildContext = (() => {
const envValue = process.env[CUSTOM_HANDLER_ENV_NAMES.BUILD_CONTEXT];
assert(envValue !== undefined);
return JSON.parse(envValue) as BuildContext;
})();
return { commandName, buildContext };
}

View File

@ -0,0 +1,46 @@
import { assert, type Equals } from "tsafe/assert";
import type { BuildContext } from "./buildContext";
import { CUSTOM_HANDLER_ENV_NAMES } from "./constants";
import {
NOT_IMPLEMENTED_EXIT_CODE,
type CommandName,
BIN_NAME,
ApiVersion
} from "./customHandler";
import * as child_process from "child_process";
import { dirname as pathDirname } from "path";
import * as fs from "fs";
assert<Equals<ApiVersion, "v1">>();
export function maybeDelegateCommandToCustomHandler(params: {
commandName: CommandName;
buildContext: BuildContext;
}) {
const { commandName, buildContext } = params;
if (!fs.readdirSync(pathDirname(process.argv[1])).includes(BIN_NAME)) {
return;
}
try {
child_process.execSync(`npx ${BIN_NAME}`, {
stdio: "inherit",
env: {
...process.env,
[CUSTOM_HANDLER_ENV_NAMES.COMMAND_NAME]: commandName,
[CUSTOM_HANDLER_ENV_NAMES.BUILD_CONTEXT]: JSON.stringify(buildContext)
}
});
} catch (error: any) {
const status = error.status;
if (status === NOT_IMPLEMENTED_EXIT_CODE) {
return;
}
process.exit(status);
}
process.exit(0);
}

View File

@ -1,53 +0,0 @@
import { transformCodebase } from "../tools/transformCodebase";
import { join as pathJoin } from "path";
import {
downloadKeycloakDefaultTheme,
type BuildContextLike as BuildContextLike_downloadKeycloakDefaultTheme
} from "./downloadKeycloakDefaultTheme";
import { RESOURCES_COMMON, type ThemeType } from "./constants";
import type { BuildContext } from "./buildContext";
import { assert } from "tsafe/assert";
import { existsAsync } from "../tools/fs.existsAsync";
export type BuildContextLike = BuildContextLike_downloadKeycloakDefaultTheme & {};
assert<BuildContext extends BuildContextLike ? true : false>();
export async function downloadKeycloakStaticResources(params: {
themeType: ThemeType;
themeDirPath: string;
keycloakVersion: string;
buildContext: BuildContextLike;
}) {
const { themeType, themeDirPath, keycloakVersion, buildContext } = params;
const { defaultThemeDirPath } = await downloadKeycloakDefaultTheme({
keycloakVersion,
buildContext
});
const resourcesDirPath = pathJoin(themeDirPath, themeType, "resources");
repatriate_base_resources: {
const srcDirPath = pathJoin(defaultThemeDirPath, "base", themeType, "resources");
if (!(await existsAsync(srcDirPath))) {
break repatriate_base_resources;
}
transformCodebase({
srcDirPath,
destDirPath: resourcesDirPath
});
}
transformCodebase({
srcDirPath: pathJoin(defaultThemeDirPath, "keycloak", themeType, "resources"),
destDirPath: resourcesDirPath
});
transformCodebase({
srcDirPath: pathJoin(defaultThemeDirPath, "keycloak", "common", "resources"),
destDirPath: pathJoin(resourcesDirPath, RESOURCES_COMMON)
});
}

View File

@ -1,175 +0,0 @@
import { assert, type Equals } from "tsafe/assert";
import { id } from "tsafe/id";
import type { BuildContext } from "./buildContext";
import * as fs from "fs/promises";
import { join as pathJoin } from "path";
import { existsAsync } from "../tools/fs.existsAsync";
import { z } from "zod";
export type BuildContextLike = {
projectDirPath: string;
themeNames: string[];
environmentVariables: { name: string; default: string }[];
themeSrcDirPath: string;
implementedThemeTypes: Pick<
BuildContext["implementedThemeTypes"],
"login" | "account"
>;
packageJsonFilePath: string;
};
assert<BuildContext extends BuildContextLike ? true : false>();
export async function generateKcGenTs(params: {
buildContext: BuildContextLike;
}): Promise<void> {
const { buildContext } = params;
const isReactProject: boolean = await (async () => {
const parsedPackageJson = await (async () => {
type ParsedPackageJson = {
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
};
const zParsedPackageJson = (() => {
type TargetType = ParsedPackageJson;
const zTargetType = z.object({
dependencies: z.record(z.string()).optional(),
devDependencies: z.record(z.string()).optional()
});
assert<Equals<z.infer<typeof zTargetType>, TargetType>>();
return id<z.ZodType<TargetType>>(zTargetType);
})();
return zParsedPackageJson.parse(
JSON.parse(
(await fs.readFile(buildContext.packageJsonFilePath)).toString("utf8")
)
);
})();
return (
{
...parsedPackageJson.dependencies,
...parsedPackageJson.devDependencies
}.react !== undefined
);
})();
const filePath = pathJoin(
buildContext.themeSrcDirPath,
`kc.gen.ts${isReactProject ? "x" : ""}`
);
const currentContent = (await existsAsync(filePath))
? await fs.readFile(filePath)
: undefined;
const hasLoginTheme = buildContext.implementedThemeTypes.login.isImplemented;
const hasAccountTheme = buildContext.implementedThemeTypes.account.isImplemented;
const newContent = Buffer.from(
[
`/* prettier-ignore-start */`,
``,
`/* eslint-disable */`,
``,
`// @ts-nocheck`,
``,
`// noinspection JSUnusedGlobalSymbols`,
``,
`// This file is auto-generated by Keycloakify`,
``,
isReactProject && `import { lazy, Suspense, type ReactNode } from "react";`,
``,
`export type ThemeName = ${buildContext.themeNames.map(themeName => `"${themeName}"`).join(" | ")};`,
``,
`export const themeNames: ThemeName[] = [${buildContext.themeNames.map(themeName => `"${themeName}"`).join(", ")}];`,
``,
`export type KcEnvName = ${buildContext.environmentVariables.length === 0 ? "never" : buildContext.environmentVariables.map(({ name }) => `"${name}"`).join(" | ")};`,
``,
`export const kcEnvNames: KcEnvName[] = [${buildContext.environmentVariables.map(({ name }) => `"${name}"`).join(", ")}];`,
``,
`export const kcEnvDefaults: Record<KcEnvName, string> = ${JSON.stringify(
Object.fromEntries(
buildContext.environmentVariables.map(
({ name, default: defaultValue }) => [name, defaultValue]
)
),
null,
2
)};`,
``,
`export type KcContext =`,
hasLoginTheme && ` | import("./login/KcContext").KcContext`,
hasAccountTheme && ` | import("./account/KcContext").KcContext`,
` ;`,
``,
`declare global {`,
` interface Window {`,
` kcContext?: KcContext;`,
` }`,
`}`,
``,
...(!isReactProject
? []
: [
hasLoginTheme &&
`export const KcLoginPage = lazy(() => import("./login/KcPage"));`,
hasAccountTheme &&
`export const KcAccountPage = lazy(() => import("./account/KcPage"));`,
``,
`export function KcPage(`,
` props: {`,
` kcContext: KcContext;`,
` fallback?: ReactNode;`,
` }`,
`) {`,
` const { kcContext, fallback } = props;`,
` return (`,
` <Suspense fallback={fallback}>`,
` {(() => {`,
` switch (kcContext.themeType) {`,
hasLoginTheme &&
` case "login": return <KcLoginPage kcContext={kcContext} />;`,
hasAccountTheme &&
` case "account": return <KcAccountPage kcContext={kcContext} />;`,
` }`,
` })()}`,
` </Suspense>`,
` );`,
`}`
]),
``,
`/* prettier-ignore-end */`,
``
]
.filter(item => typeof item === "string")
.join("\n"),
"utf8"
);
if (currentContent !== undefined && currentContent.equals(newContent)) {
return;
}
await fs.writeFile(filePath, newContent);
delete_legacy_file: {
if (!isReactProject) {
break delete_legacy_file;
}
const legacyFilePath = filePath.replace(/tsx$/, "ts");
if (!(await existsAsync(legacyFilePath))) {
break delete_legacy_file;
}
await fs.unlink(legacyFilePath);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,8 +1,7 @@
import { getBuildContext } from "../shared/buildContext";
import type { BuildContext } from "../shared/buildContext";
import { exclude } from "tsafe/exclude";
import type { CliCommandOptions as CliCommandOptions_common } from "../main";
import { promptKeycloakVersion } from "../shared/promptKeycloakVersion";
import { ACCOUNT_V1_THEME_NAME, CONTAINER_NAME } from "../shared/constants";
import { CONTAINER_NAME } from "../shared/constants";
import { SemVer } from "../tools/SemVer";
import { assert, type Equals } from "tsafe/assert";
import * as fs from "fs";
@ -29,23 +28,26 @@ import { existsAsync } from "../tools/fs.existsAsync";
import { rm } from "../tools/fs.rm";
import { downloadAndExtractArchive } from "../tools/downloadAndExtractArchive";
export type CliCommandOptions = CliCommandOptions_common & {
port: number | undefined;
keycloakVersion: string | undefined;
realmJsonFilePath: string | undefined;
};
export async function command(params: { cliCommandOptions: CliCommandOptions }) {
export async function command(params: {
buildContext: BuildContext;
cliCommandOptions: {
port: number | undefined;
keycloakVersion: string | undefined;
realmJsonFilePath: string | undefined;
};
}) {
exit_if_docker_not_installed: {
let commandOutput: Buffer | undefined = undefined;
let commandOutput: string | undefined = undefined;
try {
commandOutput = child_process.execSync("docker --version", {
stdio: ["ignore", "pipe", "ignore"]
});
commandOutput = child_process
.execSync("docker --version", {
stdio: ["ignore", "pipe", "ignore"]
})
?.toString("utf8");
} catch {}
if (commandOutput?.toString("utf8").includes("Docker")) {
if (commandOutput?.includes("Docker") || commandOutput?.includes("podman")) {
break exit_if_docker_not_installed;
}
@ -86,9 +88,7 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
process.exit(1);
}
const { cliCommandOptions } = params;
const buildContext = getBuildContext({ cliCommandOptions });
const { cliCommandOptions, buildContext } = params;
const { dockerImageTag } = await (async () => {
if (cliCommandOptions.keycloakVersion !== undefined) {
@ -409,13 +409,9 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
...[
...buildContext.themeNames,
...(fs.existsSync(
pathJoin(
buildContext.keycloakifyBuildDirPath,
"theme",
ACCOUNT_V1_THEME_NAME
)
pathJoin(buildContext.keycloakifyBuildDirPath, "theme", "account-v1")
)
? [ACCOUNT_V1_THEME_NAME]
? ["account-v1"]
: [])
]
.map(themeName => ({

View File

@ -1,16 +1,18 @@
import { type FetchOptions } from "make-fetch-happen";
import * as child_process from "child_process";
import * as fs from "fs";
import { exclude } from "tsafe/exclude";
export type ProxyFetchOptions = Pick<
FetchOptions,
"proxy" | "noProxy" | "strictSSL" | "cert" | "ca"
>;
export type FetchOptionsLike = {
proxy: string | undefined;
noProxy: string | string[];
strictSSL: boolean;
cert: string | string[] | undefined;
ca: string[] | undefined;
};
export function getProxyFetchOptions(params: {
npmConfigGetCwd: string;
}): ProxyFetchOptions {
}): FetchOptionsLike {
const { npmConfigGetCwd } = params;
const cfg = (() => {

View File

@ -1,13 +1,116 @@
import type { CliCommandOptions } from "./main";
import { getBuildContext } from "./shared/buildContext";
import { generateKcGenTs } from "./shared/generateKcGenTs";
import type { BuildContext } from "./shared/buildContext";
import * as fs from "fs/promises";
import { join as pathJoin } from "path";
import { existsAsync } from "./tools/fs.existsAsync";
import { maybeDelegateCommandToCustomHandler } from "./shared/customHandler_delegate";
export async function command(params: { cliCommandOptions: CliCommandOptions }) {
const { cliCommandOptions } = params;
export async function command(params: { buildContext: BuildContext }) {
const { buildContext } = params;
const buildContext = getBuildContext({
cliCommandOptions
maybeDelegateCommandToCustomHandler({
commandName: "update-kc-gen",
buildContext
});
await generateKcGenTs({ buildContext });
const filePath = pathJoin(buildContext.themeSrcDirPath, `kc.gen.tsx`);
const currentContent = (await existsAsync(filePath))
? await fs.readFile(filePath)
: undefined;
const hasLoginTheme = buildContext.implementedThemeTypes.login.isImplemented;
const hasAccountTheme = buildContext.implementedThemeTypes.account.isImplemented;
const newContent = Buffer.from(
[
`/* prettier-ignore-start */`,
``,
`/* eslint-disable */`,
``,
`// @ts-nocheck`,
``,
`// noinspection JSUnusedGlobalSymbols`,
``,
`// This file is auto-generated by Keycloakify`,
``,
`import { lazy, Suspense, type ReactNode } from "react";`,
``,
`export type ThemeName = ${buildContext.themeNames.map(themeName => `"${themeName}"`).join(" | ")};`,
``,
`export const themeNames: ThemeName[] = [${buildContext.themeNames.map(themeName => `"${themeName}"`).join(", ")}];`,
``,
`export type KcEnvName = ${buildContext.environmentVariables.length === 0 ? "never" : buildContext.environmentVariables.map(({ name }) => `"${name}"`).join(" | ")};`,
``,
`export const kcEnvNames: KcEnvName[] = [${buildContext.environmentVariables.map(({ name }) => `"${name}"`).join(", ")}];`,
``,
`export const kcEnvDefaults: Record<KcEnvName, string> = ${JSON.stringify(
Object.fromEntries(
buildContext.environmentVariables.map(
({ name, default: defaultValue }) => [name, defaultValue]
)
),
null,
2
)};`,
``,
`export type KcContext =`,
hasLoginTheme && ` | import("./login/KcContext").KcContext`,
hasAccountTheme && ` | import("./account/KcContext").KcContext`,
` ;`,
``,
`declare global {`,
` interface Window {`,
` kcContext?: KcContext;`,
` }`,
`}`,
``,
hasLoginTheme &&
`export const KcLoginPage = lazy(() => import("./login/KcPage"));`,
hasAccountTheme &&
`export const KcAccountPage = lazy(() => import("./account/KcPage"));`,
``,
`export function KcPage(`,
` props: {`,
` kcContext: KcContext;`,
` fallback?: ReactNode;`,
` }`,
`) {`,
` const { kcContext, fallback } = props;`,
` return (`,
` <Suspense fallback={fallback}>`,
` {(() => {`,
` switch (kcContext.themeType) {`,
hasLoginTheme &&
` case "login": return <KcLoginPage kcContext={kcContext} />;`,
hasAccountTheme &&
` case "account": return <KcAccountPage kcContext={kcContext} />;`,
` }`,
` })()}`,
` </Suspense>`,
` );`,
`}`,
``,
`/* prettier-ignore-end */`,
``
]
.filter(item => typeof item === "string")
.join("\n"),
"utf8"
);
if (currentContent !== undefined && currentContent.equals(newContent)) {
return;
}
await fs.writeFile(filePath, newContent);
delete_legacy_file: {
const legacyFilePath = filePath.replace(/tsx$/, "ts");
if (!(await existsAsync(legacyFilePath))) {
break delete_legacy_file;
}
await fs.unlink(legacyFilePath);
}
}

View File

@ -0,0 +1,252 @@
import { DOMPurify } from "keycloakify/tools/vendor/dompurify";
type TagType = {
name: string;
attributes: AttributeType[];
};
type AttributeType = {
name: string;
matchRegex?: RegExp;
matchFunction?: (value: string) => boolean;
};
// implementation for org.owasp.html.HtmlPolicyBuilder
// https://www.javadoc.io/static/com.googlecode.owasp-java-html-sanitizer/owasp-java-html-sanitizer/20160628.1/index.html?org/owasp/html/HtmlPolicyBuilder.html
// It supports the methods that KCSanitizerPolicy needs and nothing more
export class HtmlPolicyBuilder {
private globalAttributesAllowed: Set<AttributeType> = new Set();
private tagsAllowed: Map<string, TagType> = new Map();
private tagsAllowedWithNoAttribute: Set<string> = new Set();
private currentAttribute: AttributeType | null = null;
private isStylingAllowed: boolean = false;
private allowedProtocols: Set<string> = new Set();
private enforceRelNofollow: boolean = false;
private DOMPurify: typeof DOMPurify;
// add a constructor
constructor(
dependencyInjections: Partial<{
DOMPurify: typeof DOMPurify;
}>
) {
this.DOMPurify = dependencyInjections.DOMPurify ?? DOMPurify;
}
allowWithoutAttributes(tag: string): this {
this.tagsAllowedWithNoAttribute.add(tag);
return this;
}
// Adds the attributes for validation
allowAttributes(...args: string[]): this {
if (args.length) {
const attr = args[0];
this.currentAttribute = { name: attr }; // Default regex, will be set later
}
return this;
}
// Matching regex for value of allowed attributes
matching(matchingPattern: RegExp | ((value: string) => boolean)): this {
if (this.currentAttribute) {
if (matchingPattern instanceof RegExp) {
this.currentAttribute.matchRegex = matchingPattern;
} else {
this.currentAttribute.matchFunction = matchingPattern;
}
}
return this;
}
// Make attributes in prev call global
globally(): this {
if (this.currentAttribute) {
this.currentAttribute.matchRegex = /.*/;
this.globalAttributesAllowed.add(this.currentAttribute);
this.currentAttribute = null; // Reset after global application
}
return this;
}
// Allow styling globally
allowStyling(): this {
this.isStylingAllowed = true;
return this;
}
// Save attributes for specific tag
onElements(...tags: string[]): this {
if (this.currentAttribute) {
tags.forEach(tag => {
const element = this.tagsAllowed.get(tag) || {
name: tag,
attributes: []
};
element.attributes.push(this.currentAttribute!);
this.tagsAllowed.set(tag, element);
});
this.currentAttribute = null; // Reset after applying to elements
}
return this;
}
// Make specific tag allowed
allowElements(...tags: string[]): this {
tags.forEach(tag => {
if (!this.tagsAllowed.has(tag)) {
this.tagsAllowed.set(tag, { name: tag, attributes: [] });
}
});
return this;
}
// Handle rel=nofollow on links
requireRelNofollowOnLinks(): this {
this.enforceRelNofollow = true;
return this;
}
// Allow standard URL protocols (could include further implementation)
allowStandardUrlProtocols(): this {
this.allowedProtocols.add("http");
this.allowedProtocols.add("https");
this.allowedProtocols.add("mailto");
return this;
}
apply(html: string): string {
//Clear all previous configs first ( in case we used DOMPurify somewhere else )
this.DOMPurify.clearConfig();
this.DOMPurify.removeAllHooks();
this.setupHooks();
return this.DOMPurify.sanitize(html, {
ALLOWED_TAGS: Array.from(this.tagsAllowed.keys()),
ALLOWED_ATTR: this.getAllowedAttributes(),
ALLOWED_URI_REGEXP: this.getAllowedUriRegexp(),
ADD_TAGS: this.isStylingAllowed ? ["style"] : [],
ADD_ATTR: this.isStylingAllowed ? ["style"] : []
});
}
private setupHooks(): void {
// Check allowed attribute and global attributes and it doesnt exist in them remove it
this.DOMPurify.addHook("uponSanitizeAttribute", (currentNode, hookEvent) => {
if (!hookEvent) return;
const tagName = currentNode.tagName.toLowerCase();
const allowedAttributes = this.tagsAllowed.get(tagName)?.attributes || [];
//Add global attributes to allowed attributes
this.globalAttributesAllowed.forEach(attribute => {
allowedAttributes.push(attribute);
});
//Add style attribute to allowed attributes
if (this.isStylingAllowed) {
let styleAttribute: AttributeType = { name: "style", matchRegex: /.*/ };
allowedAttributes.push(styleAttribute);
}
// Check if the attribute is allowed
if (!allowedAttributes.some(attr => attr.name === hookEvent.attrName)) {
hookEvent.forceKeepAttr = false;
hookEvent.keepAttr = false;
currentNode.removeAttribute(hookEvent.attrName);
return;
} else {
const attributeType = allowedAttributes.find(
attr => attr.name === hookEvent.attrName
);
if (attributeType) {
//Check if attribute value is allowed
if (
attributeType.matchRegex &&
!attributeType.matchRegex.test(hookEvent.attrValue)
) {
hookEvent.forceKeepAttr = false;
hookEvent.keepAttr = false;
currentNode.removeAttribute(hookEvent.attrName);
return;
}
if (
attributeType.matchFunction &&
!attributeType.matchFunction(hookEvent.attrValue)
) {
hookEvent.forceKeepAttr = false;
hookEvent.keepAttr = false;
currentNode.removeAttribute(hookEvent.attrName);
return;
}
}
}
// both attribute and value already checked so they should be ok
// set forceKeep to true to make sure next hooks won't delete them
// except for href that we will check later
if (hookEvent.attrName !== "href") {
hookEvent.keepAttr = true;
hookEvent.forceKeepAttr = true;
}
});
this.DOMPurify.addHook("afterSanitizeAttributes", currentNode => {
// if tag is not allowed to have no attribute then remove it completely
if (
currentNode.attributes.length == 0 &&
currentNode.childNodes.length == 0
) {
if (!this.tagsAllowedWithNoAttribute.has(currentNode.tagName)) {
currentNode.remove();
}
} else {
//in case of <a> or <img> if we have no attribute we need to remove them even if they have child
if (currentNode.tagName === "A" || currentNode.tagName === "IMG") {
if (currentNode.attributes.length == 0) {
//add currentNode children to parent node
while (currentNode.firstChild) {
currentNode?.parentNode?.insertBefore(
currentNode.firstChild,
currentNode
);
}
// Remove the currentNode itself
currentNode.remove();
}
}
//
if (currentNode.tagName === "A") {
if (this.enforceRelNofollow) {
if (!currentNode.hasAttribute("rel")) {
currentNode.setAttribute("rel", "nofollow");
} else if (
!currentNode.getAttribute("rel")?.includes("nofollow")
) {
currentNode.setAttribute(
"rel",
currentNode.getAttribute("rel") + " nofollow"
);
}
}
}
}
});
}
private getAllowedAttributes(): string[] {
const allowedAttributes: Set<string> = new Set();
this.tagsAllowed.forEach(element => {
element.attributes.forEach(attribute => {
allowedAttributes.add(attribute.name);
});
});
this.globalAttributesAllowed.forEach(attribute => {
allowedAttributes.add(attribute.name);
});
return Array.from(allowedAttributes);
}
private getAllowedUriRegexp(): RegExp {
const protocols = Array.from(this.allowedProtocols).join("|");
return new RegExp(`^(?:${protocols})://`, "i");
}
}

View File

@ -0,0 +1,60 @@
import { KcSanitizerPolicy } from "./KcSanitizerPolicy";
import type { DOMPurify as ofTypeDomPurify } from "keycloakify/tools/vendor/dompurify";
// implementation of keycloak java sanitize method ( KeycloakSanitizerMethod )
// https://github.com/keycloak/keycloak/blob/8ce8a4ba089eef25a0e01f58e09890399477b9ef/services/src/main/java/org/keycloak/theme/KeycloakSanitizerMethod.java#L33
export class KcSanitizer {
private static HREF_PATTERN = /\s+href="([^"]*)"/g;
private static textarea: HTMLTextAreaElement | null = null;
public static sanitize(
html: string,
dependencyInjections: Partial<{
DOMPurify: typeof ofTypeDomPurify;
htmlEntitiesDecode: (html: string) => string;
}>
): string {
if (html === "") return "";
html =
dependencyInjections?.htmlEntitiesDecode !== undefined
? dependencyInjections.htmlEntitiesDecode(html)
: this.decodeHtml(html);
const sanitized = KcSanitizerPolicy.sanitize(html, dependencyInjections);
return this.fixURLs(sanitized);
}
private static decodeHtml(html: string): string {
if (!KcSanitizer.textarea) {
KcSanitizer.textarea = document.createElement("textarea");
}
KcSanitizer.textarea.innerHTML = html;
return KcSanitizer.textarea.value;
}
// This will remove unwanted characters from url
private static fixURLs(msg: string): string {
const HREF_PATTERN = this.HREF_PATTERN;
const result = [];
let last = 0;
let match: RegExpExecArray | null;
do {
match = HREF_PATTERN.exec(msg);
if (match) {
const href = match[0]
.replace(/&#61;/g, "=")
.replace(/\.\./g, ".")
.replace(/&amp;/g, "&");
result.push(msg.substring(last, match.index!));
result.push(href);
last = HREF_PATTERN.lastIndex;
}
} while (match);
result.push(msg.substring(last));
return result.join("");
}
}

View File

@ -0,0 +1,294 @@
import { HtmlPolicyBuilder } from "./HtmlPolicyBuilder";
import type { DOMPurify as ofTypeDomPurify } from "keycloakify/tools/vendor/dompurify";
//implementation of java Sanitizer policy ( KeycloakSanitizerPolicy )
// All regex directly copied from the keycloak source but some of them changed slightly to work with typescript(ONSITE_URL and OFFSITE_URL)
// Also replaced ?i with "i" tag as second parameter of RegExp
//https://github.com/keycloak/keycloak/blob/8ce8a4ba089eef25a0e01f58e09890399477b9ef/services/src/main/java/org/keycloak/theme/KeycloakSanitizerPolicy.java#L29
export class KcSanitizerPolicy {
public static readonly COLOR_NAME = new RegExp(
"(?:aqua|black|blue|fuchsia|gray|grey|green|lime|maroon|navy|olive|purple|red|silver|teal|white|yellow)"
);
public static readonly COLOR_CODE = new RegExp(
"(?:#(?:[0-9a-fA-F]{3}(?:[0-9a-fA-F]{3})?))"
);
public static readonly NUMBER_OR_PERCENT = new RegExp("[0-9]+%?");
public static readonly PARAGRAPH = new RegExp(
"(?:[\\p{L}\\p{N},'\\.\\s\\-_\\(\\)]|&[0-9]{2};)*",
"u" // Unicode flag for \p{L} and \p{N} in the pattern
);
public static readonly HTML_ID = new RegExp("[a-zA-Z0-9\\:\\-_\\.]+");
public static readonly HTML_TITLE = new RegExp(
"[\\p{L}\\p{N}\\s\\-_',:\\[\\]!\\./\\\\\\(\\)&]*",
"u" // Unicode flag for \p{L} and \p{N} in the pattern
);
public static readonly HTML_CLASS = new RegExp("[a-zA-Z0-9\\s,\\-_]+");
public static readonly ONSITE_URL = new RegExp(
"(?:[\\p{L}\\p{N}.#@\\$%+&;\\-_~,?=/!]+|#(\\w)+)",
"u" // Unicode flag for \p{L} and \p{N} in the pattern
);
public static readonly OFFSITE_URL = new RegExp(
"\\s*(?:(?:ht|f)tps?://|mailto:)[\\p{L}\\p{N}]+" +
"[\\p{L}\\p{N}\\p{Zs}.#@\\$%+&;:\\-_~,?=/!()]*\\s*",
"u" // Unicode flag for \p{L} and \p{N} in the pattern
);
public static readonly NUMBER = new RegExp(
"[+-]?(?:(?:[0-9]+(?:\\.[0-9]*)?)|\\.[0-9]+)"
);
public static readonly NAME = new RegExp("[a-zA-Z0-9\\-_\\$]+");
public static readonly ALIGN = new RegExp(
"\\b(center|left|right|justify|char)\\b",
"i" // Case-insensitive flag
);
public static readonly VALIGN = new RegExp(
"\\b(baseline|bottom|middle|top)\\b",
"i" // Case-insensitive flag
);
public static readonly HISTORY_BACK = new RegExp(
"(?:javascript:)?\\Qhistory.go(-1)\\E"
);
public static readonly ONE_CHAR = new RegExp(
".?",
"s" // Dotall flag for . to match newlines
);
private static COLOR_NAME_OR_COLOR_CODE(s: string): boolean {
return (
KcSanitizerPolicy.COLOR_NAME.test(s) || KcSanitizerPolicy.COLOR_CODE.test(s)
);
}
private static ONSITE_OR_OFFSITE_URL(s: string): boolean {
return (
KcSanitizerPolicy.ONSITE_URL.test(s) || KcSanitizerPolicy.OFFSITE_URL.test(s)
);
}
public static sanitize(
html: string,
dependencyInjections: Partial<{
DOMPurify: typeof ofTypeDomPurify;
}>
): string {
return new HtmlPolicyBuilder(dependencyInjections)
.allowWithoutAttributes("span")
.allowAttributes("id")
.matching(this.HTML_ID)
.globally()
.allowAttributes("class")
.matching(this.HTML_CLASS)
.globally()
.allowAttributes("lang")
.matching(/[a-zA-Z]{2,20}/)
.globally()
.allowAttributes("title")
.matching(this.HTML_TITLE)
.globally()
.allowStyling()
.allowAttributes("align")
.matching(this.ALIGN)
.onElements("p")
.allowAttributes("for")
.matching(this.HTML_ID)
.onElements("label")
.allowAttributes("color")
.matching(this.COLOR_NAME_OR_COLOR_CODE)
.onElements("font")
.allowAttributes("face")
.matching(/[\w;, \-]+/)
.onElements("font")
.allowAttributes("size")
.matching(this.NUMBER)
.onElements("font")
.allowAttributes("href")
.matching(this.ONSITE_OR_OFFSITE_URL)
.onElements("a")
.allowStandardUrlProtocols()
.allowAttributes("nohref")
.onElements("a")
.allowAttributes("name")
.matching(this.NAME)
.onElements("a")
.allowAttributes("onfocus", "onblur", "onclick", "onmousedown", "onmouseup")
.matching(this.HISTORY_BACK)
.onElements("a")
.requireRelNofollowOnLinks()
.allowAttributes("src")
.matching(this.ONSITE_OR_OFFSITE_URL)
.onElements("img")
.allowAttributes("name")
.matching(this.NAME)
.onElements("img")
.allowAttributes("alt")
.matching(this.PARAGRAPH)
.onElements("img")
.allowAttributes("border", "hspace", "vspace")
.matching(this.NUMBER)
.onElements("img")
.allowAttributes("border", "cellpadding", "cellspacing")
.matching(this.NUMBER)
.onElements("table")
.allowAttributes("bgcolor")
.matching(this.COLOR_NAME_OR_COLOR_CODE)
.onElements("table")
.allowAttributes("background")
.matching(this.ONSITE_URL)
.onElements("table")
.allowAttributes("align")
.matching(this.ALIGN)
.onElements("table")
.allowAttributes("noresize")
.matching(new RegExp("noresize", "i"))
.onElements("table")
.allowAttributes("background")
.matching(this.ONSITE_URL)
.onElements("td", "th", "tr")
.allowAttributes("bgcolor")
.matching(this.COLOR_NAME_OR_COLOR_CODE)
.onElements("td", "th")
.allowAttributes("abbr")
.matching(this.PARAGRAPH)
.onElements("td", "th")
.allowAttributes("axis", "headers")
.matching(this.NAME)
.onElements("td", "th")
.allowAttributes("scope")
.matching(new RegExp("(?:row|col)(?:group)?", "i"))
.onElements("td", "th")
.allowAttributes("nowrap")
.onElements("td", "th")
.allowAttributes("height", "width")
.matching(this.NUMBER_OR_PERCENT)
.onElements("table", "td", "th", "tr", "img")
.allowAttributes("align")
.matching(this.ALIGN)
.onElements(
"thead",
"tbody",
"tfoot",
"img",
"td",
"th",
"tr",
"colgroup",
"col"
)
.allowAttributes("valign")
.matching(this.VALIGN)
.onElements("thead", "tbody", "tfoot", "td", "th", "tr", "colgroup", "col")
.allowAttributes("charoff")
.matching(this.NUMBER_OR_PERCENT)
.onElements("td", "th", "tr", "colgroup", "col", "thead", "tbody", "tfoot")
.allowAttributes("char")
.matching(this.ONE_CHAR)
.onElements("td", "th", "tr", "colgroup", "col", "thead", "tbody", "tfoot")
.allowAttributes("colspan", "rowspan")
.matching(this.NUMBER)
.onElements("td", "th")
.allowAttributes("span", "width")
.matching(this.NUMBER_OR_PERCENT)
.onElements("colgroup", "col")
.allowElements(
"a",
"label",
"noscript",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"p",
"i",
"b",
"u",
"strong",
"em",
"small",
"big",
"pre",
"code",
"cite",
"samp",
"sub",
"sup",
"strike",
"center",
"blockquote",
"hr",
"br",
"col",
"font",
"map",
"span",
"div",
"img",
"ul",
"ol",
"li",
"dd",
"dt",
"dl",
"tbody",
"thead",
"tfoot",
"table",
"td",
"th",
"tr",
"colgroup",
"fieldset",
"legend"
)
.apply(html);
}
}

View File

@ -0,0 +1,5 @@
import { KcSanitizer } from "./KcSanitizer";
export function kcSanitize(html: string): string {
return KcSanitizer.sanitize(html, {});
}

View File

@ -40,6 +40,8 @@ const LoginRecoveryAuthnCodeInput = lazy(() => import("keycloakify/login/pages/L
const LoginResetOtp = lazy(() => import("keycloakify/login/pages/LoginResetOtp"));
const LoginX509Info = lazy(() => import("keycloakify/login/pages/LoginX509Info"));
const WebauthnError = lazy(() => import("keycloakify/login/pages/WebauthnError"));
const LoginPasskeysConditionalAuthenticate = lazy(() => import("keycloakify/login/pages/LoginPasskeysConditionalAuthenticate"));
const LoginIdpLinkConfirmOverride = lazy(() => import("keycloakify/login/pages/LoginIdpLinkConfirmOverride"));
type DefaultPageProps = PageProps<KcContext, I18n> & {
UserProfileFormFields: LazyOrNot<(props: UserProfileFormFieldsProps) => JSX.Element>;
@ -121,6 +123,10 @@ export default function DefaultPage(props: DefaultPageProps) {
return <LoginX509Info kcContext={kcContext} {...rest} />;
case "webauthn-error.ftl":
return <WebauthnError kcContext={kcContext} {...rest} />;
case "login-passkeys-conditional-authenticate.ftl":
return <LoginPasskeysConditionalAuthenticate kcContext={kcContext} {...rest} />;
case "login-idp-link-confirm-override.ftl":
return <LoginIdpLinkConfirmOverride kcContext={kcContext} {...rest} />;
}
assert<Equals<typeof kcContext, never>>(false);
})()}

View File

@ -2,7 +2,7 @@ import type { ThemeType, LoginThemePageId } from "keycloakify/bin/shared/constan
import type { ValueOf } from "keycloakify/tools/ValueOf";
import { assert } from "tsafe/assert";
import type { Equals } from "tsafe";
import type { ClassKey } from "keycloakify/login/TemplateProps";
import type { ClassKey } from "keycloakify/login/lib/kcClsx";
export type ExtendKcContext<
KcContextExtension extends { properties?: Record<string, string | undefined> },
@ -59,7 +59,9 @@ export type KcContext =
| KcContext.LoginRecoveryAuthnCodeInput
| KcContext.LoginResetOtp
| KcContext.LoginX509Info
| KcContext.WebauthnError;
| KcContext.WebauthnError
| KcContext.LoginPasskeysConditionalAuthenticate
| KcContext.LoginIdpLinkConfirmOverride;
assert<KcContext["themeType"] extends ThemeType ? true : false>();
@ -147,11 +149,6 @@ export declare namespace KcContext {
getFirstError: (...fieldNames: string[]) => string;
};
authenticationSession?: {
authSessionId: string;
tabId: string;
ssoLoginInOtherTabsUrl: string;
};
properties: {};
"x-keycloakify": {
messages: Record<string, string>;
@ -191,7 +188,7 @@ export declare namespace KcContext {
password?: string;
};
usernameHidden?: boolean;
social: {
social?: {
displayInfo: boolean;
providers?: {
loginUrl: string;
@ -211,9 +208,12 @@ export declare namespace KcContext {
registrationAction: string;
};
passwordRequired: boolean;
recaptchaRequired: boolean;
recaptchaRequired?: boolean;
recaptchaVisible?: boolean;
recaptchaSiteKey?: string;
recaptchaAction?: string;
termsAcceptanceRequired?: boolean;
messageHeader?: string;
};
export type Info = Common & {
@ -328,7 +328,7 @@ export declare namespace KcContext {
rememberMe?: string;
};
usernameHidden?: boolean;
social: Login["social"];
social?: Login["social"];
};
export type LoginPassword = Common & {
@ -346,9 +346,6 @@ export declare namespace KcContext {
showTryAnotherWayLink?: boolean;
attemptedUsername?: string;
};
social: {
displayInfo: boolean;
};
};
export type WebauthnAuthenticate = Common & {
@ -360,13 +357,9 @@ export declare namespace KcContext {
// I hate this:
userVerification: UserVerificationRequirement | "not specified";
rpId: string;
createTimeout: string;
createTimeout: string | number;
isUserIdentified: "true" | "false";
shouldDisplayAuthenticators: boolean;
social: {
displayInfo: boolean;
};
login: {};
realm: {
password: boolean;
registrationAllowed: boolean;
@ -401,7 +394,7 @@ export declare namespace KcContext {
authenticatorAttachment: string;
requireResidentKey: string;
userVerificationRequirement: string;
createTimeout: number;
createTimeout: number | string;
excludeCredentialIds: string;
isSetRetry?: boolean;
isAppInitiatedAction?: boolean;
@ -577,6 +570,40 @@ export declare namespace KcContext {
pageId: "webauthn-error.ftl";
isAppInitiatedAction?: boolean;
};
export type LoginPasskeysConditionalAuthenticate = Common & {
pageId: "login-passkeys-conditional-authenticate.ftl";
realm: {
registrationAllowed: boolean;
password: boolean;
};
url: {
registrationUrl: string;
};
registrationDisabled?: boolean;
isUserIdentified: boolean | "true" | "false";
challenge: string;
userVerification: string;
rpId: string;
createTimeout: number | string;
authenticators?: {
authenticators: WebauthnAuthenticate.WebauthnAuthenticator[];
};
shouldDisplayAuthenticators?: boolean;
usernameHidden?: boolean;
login: {
username?: string;
};
};
export type LoginIdpLinkConfirmOverride = Common & {
pageId: "login-idp-link-confirm-override.ftl";
url: {
loginRestartFlowUrl: string;
};
idpDisplayName: string;
};
}
export type UserProfile = {

View File

@ -1,13 +1,13 @@
import "keycloakify/tools/Object.fromEntries";
import type { KcContext, Attribute } from "./KcContext";
import {
RESOURCES_COMMON,
KEYCLOAK_RESOURCES,
WELL_KNOWN_DIRECTORY_BASE_NAME,
type LoginThemePageId
} from "keycloakify/bin/shared/constants";
import { id } from "tsafe/id";
import { assert, type Equals } from "tsafe/assert";
import { BASE_URL } from "keycloakify/lib/BASE_URL";
import type { LanguageTag } from "keycloakify/login/i18n/messages_defaultSet/types";
const attributesByName = Object.fromEntries(
id<Attribute[]>([
@ -76,7 +76,7 @@ const attributesByName = Object.fromEntries(
]).map(attribute => [attribute.name, attribute])
);
const resourcesPath = `${BASE_URL}${KEYCLOAK_RESOURCES}/login/resources`;
const resourcesPath = `${BASE_URL}${WELL_KNOWN_DIRECTORY_BASE_NAME.KEYCLOAKIFY_DEV_RESOURCES}/login`;
export const kcContextCommonMock: KcContext.Common = {
themeVersion: "0.0.0",
@ -86,7 +86,7 @@ export const kcContextCommonMock: KcContext.Common = {
url: {
loginAction: "#",
resourcesPath,
resourcesCommonPath: `${resourcesPath}/${RESOURCES_COMMON}`,
resourcesCommonPath: `${resourcesPath}/${WELL_KNOWN_DIRECTORY_BASE_NAME.RESOURCES_COMMON}`,
loginRestartFlowUrl: "#",
loginUrl: "#",
ssoLoginInOtherTabsUrl: "#"
@ -117,35 +117,59 @@ export const kcContextCommonMock: KcContext.Common = {
}
},
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"]
/* spell-checker: enable */
].map(
([languageTag, label]) =>
({
languageTag,
label,
url: "https://gist.github.com/garronej/52baaca1bb925f2296ab32741e062b8e"
}) as const
),
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"],
["el", "Ελληνικά"],
["fa", "فارسی"],
["fi", "Suomi"],
["hu", "Magyar"],
["ka", "ქართული"],
["lv", "Latviešu"],
["pt", "Português"],
["th", "ไทย"],
["uk", "Українська"],
["zh-TW", "中文繁體"]
/* 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"
},
@ -327,9 +351,6 @@ export const kcContextMocks = [
realm: {
...kcContextCommonMock.realm,
resetPasswordAllowed: true
},
social: {
displayInfo: false
}
}),
id<KcContext.WebauthnAuthenticate>({
@ -349,11 +370,7 @@ export const kcContextMocks = [
rpId: "",
createTimeout: "0",
isUserIdentified: "false",
shouldDisplayAuthenticators: false,
social: {
displayInfo: false
},
login: {}
shouldDisplayAuthenticators: false
}),
id<KcContext.LoginUpdatePassword>({
...kcContextCommonMock,
@ -567,6 +584,39 @@ export const kcContextMocks = [
pageId: "webauthn-error.ftl",
...kcContextCommonMock,
isAppInitiatedAction: true
}),
id<KcContext.LoginPasskeysConditionalAuthenticate>({
pageId: "login-passkeys-conditional-authenticate.ftl",
...kcContextCommonMock,
url: {
...kcContextCommonMock.url,
registrationUrl: "#"
},
realm: {
...kcContextCommonMock.realm,
password: true,
registrationAllowed: true
},
registrationDisabled: false,
isUserIdentified: "false",
challenge: "",
userVerification: "not specified",
rpId: "",
createTimeout: 0,
authenticators: {
authenticators: []
},
shouldDisplayAuthenticators: false,
login: {}
}),
id<KcContext.LoginIdpLinkConfirmOverride>({
pageId: "login-idp-link-confirm-override.ftl",
...kcContextCommonMock,
url: {
...kcContextCommonMock.url,
loginRestartFlowUrl: "#"
},
idpDisplayName: "Google"
})
];

View File

@ -1,11 +1,10 @@
import { useEffect } from "react";
import { assert } from "keycloakify/tools/assert";
import { clsx } from "keycloakify/tools/clsx";
import { kcSanitize } from "keycloakify/lib/kcSanitize";
import type { TemplateProps } from "keycloakify/login/TemplateProps";
import { getKcClsx } from "keycloakify/login/lib/kcClsx";
import { useInsertScriptTags } from "keycloakify/tools/useInsertScriptTags";
import { useInsertLinkTags } from "keycloakify/tools/useInsertLinkTags";
import { useSetClassName } from "keycloakify/tools/useSetClassName";
import { useInitialize } from "keycloakify/login/Template.useInitialize";
import type { I18n } from "./i18n";
import type { KcContext } from "./KcContext";
@ -28,9 +27,9 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
const { kcClsx } = getKcClsx({ doUseDefaultCss, classes });
const { msg, msgStr, getChangeLocaleUrl, labelBySupportedLanguageTag, currentLanguageTag } = i18n;
const { msg, msgStr, currentLanguage, enabledLanguages } = i18n;
const { realm, locale, auth, url, message, isAppInitiatedAction, authenticationSession, scripts } = kcContext;
const { realm, auth, url, message, isAppInitiatedAction } = kcContext;
useEffect(() => {
document.title = documentTitle ?? msgStr("loginTitle", kcContext.realm.displayName);
@ -46,71 +45,9 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
className: bodyClassName ?? kcClsx("kcBodyClass")
});
useEffect(() => {
const { currentLanguageTag } = locale ?? {};
const { isReadyToRender } = useInitialize({ kcContext, doUseDefaultCss });
if (currentLanguageTag === undefined) {
return;
}
const html = document.querySelector("html");
assert(html !== null);
html.lang = currentLanguageTag;
}, []);
const { areAllStyleSheetsLoaded } = useInsertLinkTags({
componentOrHookName: "Template",
hrefs: !doUseDefaultCss
? []
: [
`${url.resourcesCommonPath}/node_modules/@patternfly/patternfly/patternfly.min.css`,
`${url.resourcesCommonPath}/node_modules/patternfly/dist/css/patternfly.min.css`,
`${url.resourcesCommonPath}/node_modules/patternfly/dist/css/patternfly-additions.min.css`,
`${url.resourcesCommonPath}/lib/pficon/pficon.css`,
`${url.resourcesPath}/css/login.css`
]
});
const { insertScriptTags } = useInsertScriptTags({
componentOrHookName: "Template",
scriptTags: [
{
type: "module",
src: `${url.resourcesPath}/js/menu-button-links.js`
},
...(authenticationSession === undefined
? []
: [
{
type: "module",
textContent: [
`import { checkCookiesAndSetTimer } from "${url.resourcesPath}/js/authChecker.js";`,
``,
`checkCookiesAndSetTimer(`,
` "${authenticationSession.authSessionId}",`,
` "${authenticationSession.tabId}",`,
` "${url.ssoLoginInOtherTabsUrl}"`,
`);`
].join("\n")
} as const
]),
...scripts.map(
script =>
({
type: "text/javascript",
src: script
}) as const
)
]
});
useEffect(() => {
if (areAllStyleSheetsLoaded) {
insertScriptTags();
}
}, [areAllStyleSheetsLoaded]);
if (!areAllStyleSheetsLoaded) {
if (!isReadyToRender) {
return null;
}
@ -121,10 +58,9 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
{msg("loginTitleHtml", realm.displayNameHtml)}
</div>
</div>
<div className={kcClsx("kcFormCardClass")}>
<header className={kcClsx("kcFormHeaderClass")}>
{realm.internationalizationEnabled && (assert(locale !== undefined), locale.supported.length > 1) && (
{enabledLanguages.length > 1 && (
<div className={kcClsx("kcLocaleMainClass")} id="kc-locale">
<div id="kc-locale-wrapper" className={kcClsx("kcLocaleWrapperClass")}>
<div id="kc-locale-dropdown" className={clsx("menu-button-links", kcClsx("kcLocaleDropDownClass"))}>
@ -136,7 +72,7 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
aria-expanded="false"
aria-controls="language-switch1"
>
{labelBySupportedLanguageTag[currentLanguageTag]}
{currentLanguage.label}
</button>
<ul
role="menu"
@ -146,15 +82,10 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
id="language-switch1"
className={kcClsx("kcLocaleListClass")}
>
{locale.supported.map(({ languageTag }, i) => (
{enabledLanguages.map(({ languageTag, label, href }, i) => (
<li key={languageTag} className={kcClsx("kcLocaleListItemClass")} role="none">
<a
role="menuitem"
id={`language-${i + 1}`}
className={kcClsx("kcLocaleItemClass")}
href={getChangeLocaleUrl(languageTag)}
>
{labelBySupportedLanguageTag[languageTag]}
<a role="menuitem" id={`language-${i + 1}`} className={kcClsx("kcLocaleItemClass")} href={href}>
{label}
</a>
</li>
))}
@ -215,7 +146,7 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
<span
className={kcClsx("kcAlertTitleClass")}
dangerouslySetInnerHTML={{
__html: message.summary
__html: kcSanitize(message.summary)
}}
/>
</div>

View File

@ -0,0 +1,70 @@
import { useEffect } from "react";
import { assert } from "keycloakify/tools/assert";
import { useInsertScriptTags } from "keycloakify/tools/useInsertScriptTags";
import { useInsertLinkTags } from "keycloakify/tools/useInsertLinkTags";
import type { KcContext } from "keycloakify/login/KcContext";
export type KcContextLike = {
url: {
resourcesCommonPath: string;
resourcesPath: string;
ssoLoginInOtherTabsUrl: string;
};
scripts: 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, scripts } = kcContext;
const { areAllStyleSheetsLoaded } = useInsertLinkTags({
componentOrHookName: "Template",
hrefs: !doUseDefaultCss
? []
: [
`${url.resourcesCommonPath}/node_modules/@patternfly/patternfly/patternfly.min.css`,
`${url.resourcesCommonPath}/node_modules/patternfly/dist/css/patternfly.min.css`,
`${url.resourcesCommonPath}/node_modules/patternfly/dist/css/patternfly-additions.min.css`,
`${url.resourcesCommonPath}/lib/pficon/pficon.css`,
`${url.resourcesPath}/css/login.css`
]
});
const { insertScriptTags } = useInsertScriptTags({
componentOrHookName: "Template",
scriptTags: [
// NOTE: The importmap is added in by the FTL script because it's too late to add it here.
{
type: "module",
src: `${url.resourcesPath}/js/menu-button-links.js`
},
...scripts.map(src => ({
type: "text/javascript" as const,
src
})),
{
type: "module",
textContent: `
import { checkCookiesAndSetTimer } from "${url.resourcesPath}/js/authChecker.js";
checkCookiesAndSetTimer("${url.ssoLoginInOtherTabsUrl}");
`
}
]
});
useEffect(() => {
if (areAllStyleSheetsLoaded) {
insertScriptTags();
}
}, [areAllStyleSheetsLoaded]);
return { isReadyToRender: areAllStyleSheetsLoaded };
}

View File

@ -1,4 +1,5 @@
import type { ReactNode } from "react";
import type { ClassKey } from "keycloakify/login/lib/kcClsx";
export type TemplateProps<KcContext, I18n> = {
kcContext: KcContext;
@ -18,128 +19,4 @@ export type TemplateProps<KcContext, I18n> = {
bodyClassName?: string;
};
export type ClassKey =
| "kcBodyClass"
| "kcHeaderWrapperClass"
| "kcLocaleWrapperClass"
| "kcInfoAreaWrapperClass"
| "kcFormButtonsWrapperClass"
| "kcFormOptionsWrapperClass"
| "kcCheckboxInputClass"
| "kcLocaleDropDownClass"
| "kcLocaleListItemClass"
| "kcContentWrapperClass"
| "kcLogoIdP-facebook"
| "kcAuthenticatorOTPClass"
| "kcLogoIdP-bitbucket"
| "kcAuthenticatorWebAuthnClass"
| "kcWebAuthnDefaultIcon"
| "kcLogoIdP-stackoverflow"
| "kcSelectAuthListItemClass"
| "kcLogoIdP-microsoft"
| "kcLoginOTPListItemHeaderClass"
| "kcLocaleItemClass"
| "kcLoginOTPListItemIconBodyClass"
| "kcInputHelperTextAfterClass"
| "kcFormClass"
| "kcSelectAuthListClass"
| "kcInputClassRadioCheckboxLabelDisabled"
| "kcSelectAuthListItemIconClass"
| "kcRecoveryCodesWarning"
| "kcFormSettingClass"
| "kcWebAuthnBLE"
| "kcInputWrapperClass"
| "kcSelectAuthListItemArrowIconClass"
| "kcFeedbackAreaClass"
| "kcFormPasswordVisibilityButtonClass"
| "kcLogoIdP-google"
| "kcCheckLabelClass"
| "kcSelectAuthListItemFillClass"
| "kcAuthenticatorDefaultClass"
| "kcLogoIdP-gitlab"
| "kcFormAreaClass"
| "kcFormButtonsClass"
| "kcInputClassRadioLabel"
| "kcAuthenticatorWebAuthnPasswordlessClass"
| "kcSelectAuthListItemHeadingClass"
| "kcInfoAreaClass"
| "kcLogoLink"
| "kcContainerClass"
| "kcSelectAuthListItemTitle"
| "kcHtmlClass"
| "kcLoginOTPListItemTitleClass"
| "kcLogoIdP-openshift-v4"
| "kcWebAuthnUnknownIcon"
| "kcFormSocialAccountNameClass"
| "kcLogoIdP-openshift-v3"
| "kcLoginOTPListInputClass"
| "kcWebAuthnUSB"
| "kcInputClassRadio"
| "kcWebAuthnKeyIcon"
| "kcFeedbackInfoIcon"
| "kcCommonLogoIdP"
| "kcRecoveryCodesActions"
| "kcFormGroupHeader"
| "kcFormSocialAccountSectionClass"
| "kcLogoIdP-instagram"
| "kcAlertClass"
| "kcHeaderClass"
| "kcLabelWrapperClass"
| "kcFormPasswordVisibilityIconShow"
| "kcFormSocialAccountLinkClass"
| "kcLocaleMainClass"
| "kcInputGroup"
| "kcTextareaClass"
| "kcButtonBlockClass"
| "kcButtonClass"
| "kcWebAuthnNFC"
| "kcLocaleClass"
| "kcInputClassCheckboxInput"
| "kcFeedbackErrorIcon"
| "kcInputLargeClass"
| "kcInputErrorMessageClass"
| "kcRecoveryCodesList"
| "kcFormSocialAccountListClass"
| "kcAlertTitleClass"
| "kcAuthenticatorPasswordClass"
| "kcCheckInputClass"
| "kcLogoIdP-linkedin"
| "kcLogoIdP-twitter"
| "kcFeedbackWarningIcon"
| "kcResetFlowIcon"
| "kcSelectAuthListItemIconPropertyClass"
| "kcFeedbackSuccessIcon"
| "kcLoginOTPListClass"
| "kcSrOnlyClass"
| "kcFormSocialAccountListGridClass"
| "kcButtonDefaultClass"
| "kcFormGroupErrorClass"
| "kcSelectAuthListItemDescriptionClass"
| "kcSelectAuthListItemBodyClass"
| "kcWebAuthnInternal"
| "kcSelectAuthListItemArrowClass"
| "kcCheckClass"
| "kcContentClass"
| "kcLogoClass"
| "kcLoginOTPListItemIconClass"
| "kcLoginClass"
| "kcSignUpClass"
| "kcButtonLargeClass"
| "kcFormCardClass"
| "kcLocaleListClass"
| "kcInputClass"
| "kcFormGroupClass"
| "kcLogoIdP-paypal"
| "kcInputClassCheckbox"
| "kcRecoveryCodesConfirmation"
| "kcFormPasswordVisibilityIconHide"
| "kcInputClassRadioInput"
| "kcFormSocialAccountListButtonClass"
| "kcInputClassCheckboxLabel"
| "kcFormOptionsClass"
| "kcFormHeaderClass"
| "kcFormSocialAccountGridItem"
| "kcButtonPrimaryClass"
| "kcInputHelperTextBeforeClass"
| "kcLogoIdP-github"
| "kcLabelClass";
export type { ClassKey };

View File

@ -434,9 +434,7 @@ function AddRemoveButtonsMultiValuedAttribute(props: {
}
function InputTagSelects(props: InputFieldByTypeProps) {
const { attribute, dispatchFormAction, kcClsx, valueOrValues } = props;
const { advancedMsg } = props.i18n;
const { attribute, dispatchFormAction, kcClsx, i18n, valueOrValues } = props;
const { classDiv, classInput, classLabel, inputType } = (() => {
const { inputType } = attribute.annotations;
@ -533,7 +531,7 @@ function InputTagSelects(props: InputFieldByTypeProps) {
htmlFor={`${attribute.name}-${option}`}
className={`${classLabel}${attribute.readOnly ? ` ${kcClsx("kcInputClassRadioCheckboxLabelDisabled")}` : ""}`}
>
{advancedMsg(option)}
{inputLabel(i18n, attribute, option)}
</label>
</div>
))}
@ -580,8 +578,6 @@ function TextareaTag(props: InputFieldByTypeProps) {
function SelectTag(props: InputFieldByTypeProps) {
const { attribute, dispatchFormAction, kcClsx, displayableErrors, i18n, valueOrValues } = props;
const { advancedMsgStr } = i18n;
const isMultiple = attribute.annotations.inputType === "multiselect";
return (
@ -645,22 +641,26 @@ function SelectTag(props: InputFieldByTypeProps) {
return options.map(option => (
<option key={option} value={option}>
{(() => {
if (attribute.annotations.inputOptionLabels !== undefined) {
const { inputOptionLabels } = attribute.annotations;
return advancedMsgStr(inputOptionLabels[option] ?? option);
}
if (attribute.annotations.inputOptionLabelsI18nPrefix !== undefined) {
return advancedMsgStr(`${attribute.annotations.inputOptionLabelsI18nPrefix}.${option}`);
}
return option;
})()}
{inputLabel(i18n, attribute, option)}
</option>
));
})()}
</select>
);
}
function inputLabel(i18n: I18n, attribute: Attribute, option: string) {
const { advancedMsg } = i18n;
if (attribute.annotations.inputOptionLabels !== undefined) {
const { inputOptionLabels } = attribute.annotations;
return advancedMsg(inputOptionLabels[option] ?? option);
}
if (attribute.annotations.inputOptionLabelsI18nPrefix !== undefined) {
return advancedMsg(`${attribute.annotations.inputOptionLabelsI18nPrefix}.${option}`);
}
return option;
}

View File

@ -1,6 +0,0 @@
import type { GenericI18n_noJsx } from "./i18n";
export type GenericI18n<MessageKey extends string> = GenericI18n_noJsx<MessageKey> & {
msg: (key: MessageKey, ...args: (string | undefined)[]) => JSX.Element;
advancedMsg: (key: string, ...args: (string | undefined)[]) => JSX.Element;
};

View File

@ -1,252 +0,0 @@
import "keycloakify/tools/Object.fromEntries";
import { assert } from "tsafe/assert";
import messages_defaultSet_fallbackLanguage from "./messages_defaultSet/en";
import { fetchMessages_defaultSet } from "./messages_defaultSet";
import type { KcContext } from "../KcContext";
import { FALLBACK_LANGUAGE_TAG } from "keycloakify/bin/shared/constants";
import { id } from "tsafe/id";
export type KcContextLike = {
locale?: {
currentLanguageTag: string;
supported: { languageTag: string; url: string; label: string }[];
};
"x-keycloakify": {
messages: Record<string, string>;
};
};
assert<KcContext extends KcContextLike ? true : false>();
export type GenericI18n_noJsx<MessageKey extends string> = {
/**
* e.g: "en", "fr", "zh-CN"
*
* The current language
*/
currentLanguageTag: string;
/**
* Redirect to this url to change the language.
* After reload currentLanguageTag === newLanguageTag
*/
getChangeLocaleUrl: (newLanguageTag: string) => string;
/**
* 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"
* {
* en: {
* "access-denied": "Access denied",
* "impersonateTitleHtml": "<strong>{0}</strong> Impersonate User",
* "bar": "Bar {0}"
* }
* }
*
* msgStr("access-denied") === "Access denied"
* msgStr("not-a-message-key") Throws an error
* msgStr("impersonateTitleHtml", "Foo") === "<strong>Foo</strong> Impersonate User"
* msgStr("${bar}", "<strong>c</strong>") === "Bar &lt;strong&gt;XXX&lt;/strong&gt;"
* The html in the arg is partially escaped for security reasons, it might come from an untrusted source, it's not safe to render it as html.
*/
msgStr: (key: MessageKey, ...args: (string | undefined)[]) => string;
/**
* This is meant to be used when the key argument is variable, something that might have been configured by the user
* in the Keycloak admin for example.
*
* Examples assuming currentLanguageTag === "en"
* {
* en: {
* "access-denied": "Access denied",
* }
* }
*
* advancedMsgStr("${access-denied}") === advancedMsgStr("access-denied") === msgStr("access-denied") === "Access denied"
* advancedMsgStr("${not-a-message-key}") === advancedMsgStr("not-a-message-key") === "not-a-message-key"
*/
advancedMsgStr: (key: string, ...args: (string | undefined)[]) => string;
/**
* Initially the messages are in english (fallback language).
* The translations in the current language are being fetched dynamically.
* This property is true while the translations are being fetched.
*/
isFetchingTranslations: boolean;
};
export type MessageKey_defaultSet = keyof typeof messages_defaultSet_fallbackLanguage;
export function createGetI18n<MessageKey_themeDefined extends string = never>(messagesByLanguageTag_themeDefined: {
[languageTag: string]: { [key in MessageKey_themeDefined]: string };
}) {
type I18n = GenericI18n_noJsx<MessageKey_defaultSet | MessageKey_themeDefined>;
type Result = { i18n: I18n; prI18n_currentLanguage: Promise<I18n> | undefined };
const cachedResultByKcContext = new WeakMap<KcContextLike, Result>();
function getI18n(params: { kcContext: KcContextLike }): Result {
const { kcContext } = params;
use_cache: {
const cachedResult = cachedResultByKcContext.get(kcContext);
if (cachedResult === undefined) {
break use_cache;
}
return cachedResult;
}
const partialI18n: Pick<I18n, "currentLanguageTag" | "getChangeLocaleUrl" | "labelBySupportedLanguageTag"> = {
currentLanguageTag: kcContext.locale?.currentLanguageTag ?? FALLBACK_LANGUAGE_TAG,
getChangeLocaleUrl: 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`);
return targetSupportedLocale.url;
},
labelBySupportedLanguageTag: Object.fromEntries((kcContext.locale?.supported ?? []).map(({ languageTag, label }) => [languageTag, label]))
};
const { createI18nTranslationFunctions } = createI18nTranslationFunctionsFactory<MessageKey_themeDefined>({
messages_themeDefined:
messagesByLanguageTag_themeDefined[partialI18n.currentLanguageTag] ??
messagesByLanguageTag_themeDefined[FALLBACK_LANGUAGE_TAG] ??
(() => {
const firstLanguageTag = Object.keys(messagesByLanguageTag_themeDefined)[0];
if (firstLanguageTag === undefined) {
return undefined;
}
return messagesByLanguageTag_themeDefined[firstLanguageTag];
})(),
messages_fromKcServer: kcContext["x-keycloakify"].messages
});
const isCurrentLanguageFallbackLanguage = partialI18n.currentLanguageTag === FALLBACK_LANGUAGE_TAG;
const result: Result = {
i18n: {
...partialI18n,
...createI18nTranslationFunctions({
messages_defaultSet_currentLanguage: isCurrentLanguageFallbackLanguage ? messages_defaultSet_fallbackLanguage : undefined
}),
isFetchingTranslations: !isCurrentLanguageFallbackLanguage
},
prI18n_currentLanguage: isCurrentLanguageFallbackLanguage
? undefined
: (async () => {
const messages_defaultSet_currentLanguage = await fetchMessages_defaultSet(partialI18n.currentLanguageTag);
const i18n_currentLanguage: I18n = {
...partialI18n,
...createI18nTranslationFunctions({ messages_defaultSet_currentLanguage }),
isFetchingTranslations: false
};
// NOTE: This promise.resolve is just because without it we TypeScript
// gives a Variable 'result' is used before being assigned. error
await Promise.resolve().then(() => {
result.i18n = i18n_currentLanguage;
result.prI18n_currentLanguage = undefined;
});
return i18n_currentLanguage;
})()
};
cachedResultByKcContext.set(kcContext, result);
return result;
}
return { getI18n };
}
function createI18nTranslationFunctionsFactory<MessageKey_themeDefined extends string>(params: {
messages_themeDefined: Record<MessageKey_themeDefined, string> | undefined;
messages_fromKcServer: Record<string, string>;
}) {
const { messages_themeDefined, messages_fromKcServer } = params;
function createI18nTranslationFunctions(params: {
messages_defaultSet_currentLanguage: Partial<Record<MessageKey_defaultSet, string>> | undefined;
}): Pick<GenericI18n_noJsx<MessageKey_defaultSet | MessageKey_themeDefined>, "msgStr" | "advancedMsgStr"> {
const { messages_defaultSet_currentLanguage } = params;
function resolveMsg(props: { key: string; args: (string | undefined)[] }): string | undefined {
const { key, args } = props;
const message =
id<Record<string, string | undefined>>(messages_fromKcServer)[key] ??
id<Record<string, string | undefined> | undefined>(messages_themeDefined)?.[key] ??
id<Record<string, string | undefined> | undefined>(messages_defaultSet_currentLanguage)?.[key] ??
id<Record<string, string | undefined>>(messages_defaultSet_fallbackLanguage)[key];
if (message === undefined) {
return undefined;
}
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"),
(() => {
if (key === "loginTitleHtml") {
return arg;
}
return arg.replace(/</g, "&lt;").replace(/>/g, "&gt;");
})()
);
});
return messageWithArgsInjected;
}
function resolveMsgAdvanced(props: { key: string; args: (string | undefined)[] }): string {
const { key, args } = props;
const match = key.match(/^\$\{(.+)\}$/);
return resolveMsg({ key: match !== null ? match[1] : key, args }) ?? key;
}
return {
msgStr: (key, ...args) => {
const resolvedMessage = resolveMsg({ key, args });
assert(resolvedMessage !== undefined, `Message with key "${key}" not found`);
return resolvedMessage;
},
advancedMsgStr: (key, ...args) => resolveMsgAdvanced({ key, args })
};
}
return { createI18nTranslationFunctions };
}

View File

@ -1,5 +1,5 @@
import type { GenericI18n } from "./GenericI18n";
import type { MessageKey_defaultSet, KcContextLike } from "./i18n";
export type { MessageKey_defaultSet, KcContextLike };
export type I18n = GenericI18n<MessageKey_defaultSet>;
export { createUseI18n } from "./useI18n";
export * from "./withJsx";
import type { GenericI18n } from "./withJsx/GenericI18n";
import type { MessageKey as MessageKey_defaultSet } from "./messages_defaultSet/types";
/** INTERNAL: DO NOT IMPORT THIS */
export type I18n = GenericI18n<MessageKey_defaultSet, string>;

View File

@ -0,0 +1,64 @@
export type GenericI18n_noJsx<MessageKey extends string, LanguageTag extends string> = {
currentLanguage: {
/**
* e.g: "en", "fr", "zh-CN"
*
* The current language
*/
languageTag: LanguageTag;
/**
* e.g: "English", "Français", "中文(简体)"
*
* The current language
*/
label: string;
};
/**
* Array of languages enabled on the realm.
*/
enabledLanguages: {
languageTag: LanguageTag;
label: string;
href: string;
}[];
/**
*
* Examples assuming currentLanguageTag === "en"
* {
* en: {
* "access-denied": "Access denied",
* "impersonateTitleHtml": "<strong>{0}</strong> Impersonate User",
* "bar": "Bar {0}"
* }
* }
*
* msgStr("access-denied") === "Access denied"
* msgStr("not-a-message-key") Throws an error
* msgStr("impersonateTitleHtml", "Foo") === "<strong>Foo</strong> Impersonate User"
* msgStr("${bar}", "<strong>c</strong>") === "Bar &lt;strong&gt;XXX&lt;/strong&gt;"
* The html in the arg is partially escaped for security reasons, it might come from an untrusted source, it's not safe to render it as html.
*/
msgStr: (key: MessageKey, ...args: (string | undefined)[]) => string;
/**
* This is meant to be used when the key argument is variable, something that might have been configured by the user
* in the Keycloak admin for example.
*
* Examples assuming currentLanguageTag === "en"
* {
* en: {
* "access-denied": "Access denied",
* }
* }
*
* advancedMsgStr("${access-denied}") === advancedMsgStr("access-denied") === msgStr("access-denied") === "Access denied"
* advancedMsgStr("${not-a-message-key}") === advancedMsgStr("not-a-message-key") === "not-a-message-key"
*/
advancedMsgStr: (key: string, ...args: (string | undefined)[]) => string;
/**
* Initially the messages are in english (fallback language).
* The translations in the current language are being fetched dynamically.
* This property is true while the translations are being fetched.
*/
isFetchingTranslations: boolean;
};

View File

@ -0,0 +1,341 @@
import "keycloakify/tools/Object.fromEntries";
import { assert } from "tsafe/assert";
import messages_defaultSet_fallbackLanguage from "../messages_defaultSet/en";
import { fetchMessages_defaultSet } from "../messages_defaultSet";
import type { KcContext } from "../../KcContext";
import { FALLBACK_LANGUAGE_TAG } from "keycloakify/bin/shared/constants";
import { id } from "tsafe/id";
import { is } from "tsafe/is";
import { Reflect } from "tsafe/Reflect";
import {
type LanguageTag as LanguageTag_defaultSet,
type MessageKey as MessageKey_defaultSet,
languageTags as languageTags_defaultSet
} from "../messages_defaultSet/types";
import type { GenericI18n_noJsx } from "./GenericI18n_noJsx";
export type KcContextLike = {
themeName: string;
locale?: {
currentLanguageTag: string;
supported: { languageTag: string; url: string; label: string }[];
};
"x-keycloakify": {
messages: Record<string, string>;
};
};
assert<KcContext extends KcContextLike ? true : false>();
export type ReturnTypeOfCreateGetI18n<MessageKey_themeDefined extends string, LanguageTag_notInDefaultSet extends string> = {
getI18n: (params: { kcContext: KcContextLike }) => {
i18n: GenericI18n_noJsx<MessageKey_defaultSet | MessageKey_themeDefined, LanguageTag_defaultSet | LanguageTag_notInDefaultSet>;
prI18n_currentLanguage:
| Promise<GenericI18n_noJsx<MessageKey_defaultSet | MessageKey_themeDefined, LanguageTag_defaultSet | LanguageTag_notInDefaultSet>>
| undefined;
};
ofTypeI18n: GenericI18n_noJsx<MessageKey_defaultSet | MessageKey_themeDefined, LanguageTag_defaultSet | LanguageTag_notInDefaultSet>;
};
export function createGetI18n<
ThemeName extends string = string,
MessageKey_themeDefined extends string = never,
LanguageTag_notInDefaultSet extends string = never
>(params: {
extraLanguageTranslations: {
[languageTag in LanguageTag_notInDefaultSet]: {
label: string;
getMessages: () => Promise<{ default: Record<MessageKey_defaultSet, string> }>;
};
};
messagesByLanguageTag_themeDefined: Partial<{
[languageTag in LanguageTag_defaultSet | LanguageTag_notInDefaultSet]: {
[key in MessageKey_themeDefined]: string | Record<ThemeName, string>;
};
}>;
}): ReturnTypeOfCreateGetI18n<MessageKey_themeDefined, LanguageTag_notInDefaultSet> {
const { extraLanguageTranslations, messagesByLanguageTag_themeDefined } = params;
Object.keys(extraLanguageTranslations).forEach(languageTag_notInDefaultSet => {
if (id<readonly string[]>(languageTags_defaultSet).includes(languageTag_notInDefaultSet)) {
throw new Error(
[
`Language "${languageTag_notInDefaultSet}" is already in the default set, you don't need to provide your own base translations for it`,
`If you want to override some translations for this language, you can use the "withCustomTranslations" method`
].join(" ")
);
}
});
type LanguageTag = LanguageTag_defaultSet | LanguageTag_notInDefaultSet;
type MessageKey = MessageKey_defaultSet | MessageKey_themeDefined;
type I18n = GenericI18n_noJsx<MessageKey, LanguageTag>;
type Result = { i18n: I18n; prI18n_currentLanguage: Promise<I18n> | undefined };
const cachedResultByKcContext = new WeakMap<KcContextLike, Result>();
function getI18n(params: { kcContext: KcContextLike }): Result {
const { kcContext } = params;
use_cache: {
const cachedResult = cachedResultByKcContext.get(kcContext);
if (cachedResult === undefined) {
break use_cache;
}
return cachedResult;
}
{
const currentLanguageTag = kcContext.locale?.currentLanguageTag ?? FALLBACK_LANGUAGE_TAG;
const html = document.querySelector("html");
assert(html !== null);
html.lang = currentLanguageTag;
}
const getLanguageLabel = (languageTag: LanguageTag) => {
form_user_added_languages: {
if (!(languageTag in extraLanguageTranslations)) {
break form_user_added_languages;
}
assert(is<Exclude<LanguageTag, LanguageTag_defaultSet>>(languageTag));
const entry = extraLanguageTranslations[languageTag];
return entry.label;
}
from_server: {
if (kcContext.locale === undefined) {
break from_server;
}
const supportedEntry = kcContext.locale.supported.find(entry => entry.languageTag === languageTag);
if (supportedEntry === undefined) {
break from_server;
}
// cspell: disable-next-line
// from "Espagnol (Español)" we want to extract "Español"
const match = supportedEntry.label.match(/[^(]+\(([^)]+)\)/);
if (match !== null) {
return match[1];
}
return supportedEntry.label;
}
// NOTE: This should never happen
return languageTag;
};
const currentLanguage: I18n["currentLanguage"] = (() => {
const languageTag = id<string>(kcContext.locale?.currentLanguageTag ?? FALLBACK_LANGUAGE_TAG) as LanguageTag;
return {
languageTag,
label: getLanguageLabel(languageTag)
};
})();
const enabledLanguages: I18n["enabledLanguages"] = (() => {
const enabledLanguages: I18n["enabledLanguages"] = [];
if (kcContext.locale !== undefined) {
for (const entry of kcContext.locale.supported ?? []) {
const languageTag = id<string>(entry.languageTag) as LanguageTag;
enabledLanguages.push({
languageTag,
label: getLanguageLabel(languageTag),
href: entry.url
});
}
}
if (enabledLanguages.find(({ languageTag }) => languageTag === currentLanguage.languageTag) === undefined) {
enabledLanguages.push({
languageTag: currentLanguage.languageTag,
label: getLanguageLabel(currentLanguage.languageTag),
href: "#"
});
}
return enabledLanguages;
})();
const { createI18nTranslationFunctions } = createI18nTranslationFunctionsFactory<MessageKey_themeDefined>({
themeName: kcContext.themeName,
messages_themeDefined:
messagesByLanguageTag_themeDefined[currentLanguage.languageTag] ??
messagesByLanguageTag_themeDefined[id<string>(FALLBACK_LANGUAGE_TAG) as LanguageTag] ??
(() => {
const firstLanguageTag = Object.keys(messagesByLanguageTag_themeDefined)[0];
if (firstLanguageTag === undefined) {
return undefined;
}
return messagesByLanguageTag_themeDefined[firstLanguageTag as LanguageTag];
})(),
messages_fromKcServer: kcContext["x-keycloakify"].messages
});
const isCurrentLanguageFallbackLanguage = currentLanguage.languageTag === FALLBACK_LANGUAGE_TAG;
const result: Result = {
i18n: {
currentLanguage,
enabledLanguages,
...createI18nTranslationFunctions({
messages_defaultSet_currentLanguage: isCurrentLanguageFallbackLanguage ? messages_defaultSet_fallbackLanguage : undefined
}),
isFetchingTranslations: !isCurrentLanguageFallbackLanguage
},
prI18n_currentLanguage: isCurrentLanguageFallbackLanguage
? undefined
: (async () => {
const messages_defaultSet_currentLanguage = await (async () => {
const currentLanguageTag = currentLanguage.languageTag;
const fromDefaultSet = await fetchMessages_defaultSet(currentLanguageTag);
const isEmpty = (() => {
for (let _key in fromDefaultSet) {
return false;
}
return true;
})();
if (isEmpty) {
assert(is<Exclude<LanguageTag, LanguageTag_defaultSet>>(currentLanguageTag));
const entry = extraLanguageTranslations[currentLanguageTag];
assert(entry !== undefined);
return entry.getMessages().then(({ default: messages }) => messages);
}
return fromDefaultSet;
})();
const i18n_currentLanguage: I18n = {
currentLanguage,
enabledLanguages,
...createI18nTranslationFunctions({ messages_defaultSet_currentLanguage }),
isFetchingTranslations: false
};
// NOTE: This promise.resolve is just because without it we TypeScript
// gives a Variable 'result' is used before being assigned. error
await Promise.resolve().then(() => {
result.i18n = i18n_currentLanguage;
result.prI18n_currentLanguage = undefined;
});
return i18n_currentLanguage;
})()
};
cachedResultByKcContext.set(kcContext, result);
return result;
}
return {
getI18n,
ofTypeI18n: Reflect<I18n>()
};
}
function createI18nTranslationFunctionsFactory<MessageKey_themeDefined extends string>(params: {
themeName: string;
messages_themeDefined: Record<MessageKey_themeDefined, string | Record<string, string>> | undefined;
messages_fromKcServer: Record<string, string>;
}) {
const { themeName, messages_themeDefined, messages_fromKcServer } = params;
function createI18nTranslationFunctions(params: {
messages_defaultSet_currentLanguage: Partial<Record<MessageKey_defaultSet, string>> | undefined;
}): Pick<GenericI18n_noJsx<MessageKey_defaultSet | MessageKey_themeDefined, string>, "msgStr" | "advancedMsgStr"> {
const { messages_defaultSet_currentLanguage } = params;
function resolveMsg(props: { key: string; args: (string | undefined)[] }): string | undefined {
const { key, args } = props;
const message =
id<Record<string, string | undefined>>(messages_fromKcServer)[key] ??
(() => {
const messageOrMap = id<Record<string, string | Record<string, string> | undefined> | undefined>(messages_themeDefined)?.[key];
if (messageOrMap === undefined) {
return undefined;
}
if (typeof messageOrMap === "string") {
return messageOrMap;
}
const message = messageOrMap[themeName];
assert(message !== undefined, `No translation for theme variant "${themeName}" for key "${key}"`);
return message;
})() ??
id<Record<string, string | undefined> | undefined>(messages_defaultSet_currentLanguage)?.[key] ??
id<Record<string, string | undefined>>(messages_defaultSet_fallbackLanguage)[key];
if (message === undefined) {
return undefined;
}
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;
}
function resolveMsgAdvanced(props: { key: string; args: (string | undefined)[] }): string {
const { key, args } = props;
const match = key.match(/^\$\{(.+)\}$/);
return resolveMsg({ key: match !== null ? match[1] : key, args }) ?? key;
}
return {
msgStr: (key, ...args) => {
const resolvedMessage = resolveMsg({ key, args });
assert(resolvedMessage !== undefined, `Message with key "${key}" not found`);
return resolvedMessage;
},
advancedMsgStr: (key, ...args) => resolveMsgAdvanced({ key, args })
};
}
return { createI18nTranslationFunctions };
}

View File

@ -0,0 +1,117 @@
import type {
LanguageTag as LanguageTag_defaultSet,
MessageKey as MessageKey_defaultSet
} from "../messages_defaultSet/types";
import { type ReturnTypeOfCreateGetI18n, createGetI18n } from "./getI18n";
export type I18nBuilder<
ThemeName extends string = never,
MessageKey_themeDefined extends string = never,
LanguageTag_notInDefaultSet extends string = never,
ExcludedMethod extends
| "withThemeName"
| "withExtraLanguages"
| "withCustomTranslations" = never
> = Omit<
{
withThemeName: <ThemeName extends string>() => I18nBuilder<
ThemeName,
MessageKey_themeDefined,
LanguageTag_notInDefaultSet,
ExcludedMethod | "withThemeName"
>;
withExtraLanguages: <
LanguageTag_notInDefaultSet extends string
>(extraLanguageTranslations: {
[LanguageTag in LanguageTag_notInDefaultSet]: {
label: string;
getMessages: () => Promise<{
default: Record<MessageKey_defaultSet, string>;
}>;
};
}) => I18nBuilder<
ThemeName,
MessageKey_themeDefined,
LanguageTag_notInDefaultSet,
ExcludedMethod | "withExtraLanguages"
>;
withCustomTranslations: <MessageKey_themeDefined extends string>(
messagesByLanguageTag_themeDefined: Partial<{
[LanguageTag in
| LanguageTag_defaultSet
| LanguageTag_notInDefaultSet]: Record<
MessageKey_themeDefined,
string | Record<ThemeName, string>
>;
}>
) => I18nBuilder<
ThemeName,
MessageKey_themeDefined,
LanguageTag_notInDefaultSet,
ExcludedMethod | "withCustomTranslations"
>;
build: () => ReturnTypeOfCreateGetI18n<
MessageKey_themeDefined,
LanguageTag_notInDefaultSet
>;
},
ExcludedMethod
>;
function createI18nBuilder<
ThemeName extends string = never,
MessageKey_themeDefined extends string = never,
LanguageTag_notInDefaultSet extends string = never
>(params: {
extraLanguageTranslations: {
[LanguageTag in LanguageTag_notInDefaultSet]: {
label: string;
getMessages: () => Promise<{
default: Record<MessageKey_defaultSet, string>;
}>;
};
};
messagesByLanguageTag_themeDefined: Partial<{
[LanguageTag in LanguageTag_defaultSet | LanguageTag_notInDefaultSet]: Record<
MessageKey_themeDefined,
string | Record<ThemeName, string>
>;
}>;
}): I18nBuilder<ThemeName, MessageKey_themeDefined, LanguageTag_notInDefaultSet> {
const i18nBuilder: I18nBuilder<
ThemeName,
MessageKey_themeDefined,
LanguageTag_notInDefaultSet
> = {
withThemeName: () =>
createI18nBuilder({
extraLanguageTranslations: params.extraLanguageTranslations,
messagesByLanguageTag_themeDefined:
params.messagesByLanguageTag_themeDefined as any
}),
withExtraLanguages: extraLanguageTranslations =>
createI18nBuilder({
extraLanguageTranslations,
messagesByLanguageTag_themeDefined:
params.messagesByLanguageTag_themeDefined as any
}),
withCustomTranslations: messagesByLanguageTag_themeDefined =>
createI18nBuilder({
extraLanguageTranslations: params.extraLanguageTranslations,
messagesByLanguageTag_themeDefined
}),
build: () =>
createGetI18n({
extraLanguageTranslations: params.extraLanguageTranslations,
messagesByLanguageTag_themeDefined:
params.messagesByLanguageTag_themeDefined
})
};
return i18nBuilder;
}
export const i18nBuilder = createI18nBuilder({
extraLanguageTranslations: {},
messagesByLanguageTag_themeDefined: {}
});

View File

@ -0,0 +1,3 @@
export { i18nBuilder } from "./i18nBuilder";
export type { KcContextLike } from "./getI18n";
export type { MessageKey as MessageKey_defaultSet } from "../messages_defaultSet/types";

View File

@ -1,95 +0,0 @@
import { useEffect, useState } from "react";
import { createGetI18n, type GenericI18n_noJsx, type KcContextLike, type MessageKey_defaultSet } from "./i18n";
import { GenericI18n } from "./GenericI18n";
import { Reflect } from "tsafe/Reflect";
export function createUseI18n<MessageKey_themeDefined extends string = never>(messagesByLanguageTag: {
[languageTag: string]: { [key in MessageKey_themeDefined]: string };
}) {
type MessageKey = MessageKey_defaultSet | MessageKey_themeDefined;
type I18n = GenericI18n<MessageKey>;
const { withJsx } = (() => {
const cache = new WeakMap<GenericI18n_noJsx<MessageKey>, GenericI18n<MessageKey>>();
function renderHtmlString(params: { htmlString: string; msgKey: string }): JSX.Element {
const { htmlString, msgKey } = params;
return (
<div
data-kc-msg={msgKey}
dangerouslySetInnerHTML={{
__html: htmlString
}}
/>
);
}
function withJsx(i18n_noJsx: GenericI18n_noJsx<MessageKey>): I18n {
use_cache: {
const i18n = cache.get(i18n_noJsx);
if (i18n === undefined) {
break use_cache;
}
return i18n;
}
const i18n: I18n = {
...i18n_noJsx,
msg: (msgKey, ...args) => renderHtmlString({ htmlString: i18n_noJsx.msgStr(msgKey, ...args), msgKey }),
advancedMsg: (msgKey, ...args) => renderHtmlString({ htmlString: i18n_noJsx.advancedMsgStr(msgKey, ...args), msgKey })
};
cache.set(i18n_noJsx, i18n);
return i18n;
}
return { withJsx };
})();
add_style: {
const attributeName = "data-kc-i18n";
// Check if already exists in head
if (document.querySelector(`style[${attributeName}]`) !== null) {
break add_style;
}
const styleElement = document.createElement("style");
styleElement.attributes.setNamedItem(document.createAttribute(attributeName));
(styleElement.textContent = `[data-kc-msg] { display: inline-block; }`), document.head.prepend(styleElement);
}
const { getI18n } = createGetI18n(messagesByLanguageTag);
function useI18n(params: { kcContext: KcContextLike }): { i18n: I18n } {
const { kcContext } = params;
const { i18n, prI18n_currentLanguage } = getI18n({ kcContext });
const [i18n_toReturn, setI18n_toReturn] = useState<I18n>(withJsx(i18n));
useEffect(() => {
let isActive = true;
prI18n_currentLanguage?.then(i18n => {
if (!isActive) {
return;
}
setI18n_toReturn(withJsx(i18n));
});
return () => {
isActive = false;
};
}, []);
return { i18n: i18n_toReturn };
}
return { useI18n, ofTypeI18n: Reflect<I18n>() };
}

View File

@ -0,0 +1,81 @@
import type { GenericI18n_noJsx } from "../noJsx/GenericI18n_noJsx";
import { assert, type Equals } from "tsafe/assert";
export type GenericI18n<MessageKey extends string, LanguageTag extends string> = {
currentLanguage: {
/**
* e.g: "en", "fr", "zh-CN"
*
* The current language
*/
languageTag: LanguageTag;
/**
* e.g: "English", "Français", "中文(简体)"
*
* The current language
*/
label: string;
};
/**
* Array of languages enabled on the realm.
*/
enabledLanguages: {
languageTag: LanguageTag;
label: string;
href: string;
}[];
/**
*
* Examples assuming currentLanguageTag === "en"
* {
* en: {
* "access-denied": "Access denied",
* "impersonateTitleHtml": "<strong>{0}</strong> Impersonate User",
* "bar": "Bar {0}"
* }
* }
*
* msgStr("access-denied") === "Access denied"
* msgStr("not-a-message-key") Throws an error
* msgStr("impersonateTitleHtml", "Foo") === "<strong>Foo</strong> Impersonate User"
* msgStr("${bar}", "<strong>c</strong>") === "Bar &lt;strong&gt;XXX&lt;/strong&gt;"
* The html in the arg is partially escaped for security reasons, it might come from an untrusted source, it's not safe to render it as html.
*/
msgStr: (key: MessageKey, ...args: (string | undefined)[]) => string;
/**
* This is meant to be used when the key argument is variable, something that might have been configured by the user
* in the Keycloak admin for example.
*
* Examples assuming currentLanguageTag === "en"
* {
* en: {
* "access-denied": "Access denied",
* }
* }
*
* advancedMsgStr("${access-denied}") === advancedMsgStr("access-denied") === msgStr("access-denied") === "Access denied"
* advancedMsgStr("${not-a-message-key}") === advancedMsgStr("not-a-message-key") === "not-a-message-key"
*/
advancedMsgStr: (key: string, ...args: (string | undefined)[]) => string;
/**
* Initially the messages are in english (fallback language).
* The translations in the current language are being fetched dynamically.
* This property is true while the translations are being fetched.
*/
isFetchingTranslations: boolean;
/**
* Same as msgStr but returns a JSX.Element with the html string rendered as html.
*/
msg: (key: MessageKey, ...args: (string | undefined)[]) => JSX.Element;
/**
* Same as advancedMsgStr but returns a JSX.Element with the html string rendered as html.
*/
advancedMsg: (key: string, ...args: (string | undefined)[]) => JSX.Element;
};
{
type A = Omit<GenericI18n<string, string>, "msg" | "advancedMsg">;
type B = GenericI18n_noJsx<string, string>;
assert<Equals<A, B>>;
}

View File

@ -0,0 +1,117 @@
import type {
LanguageTag as LanguageTag_defaultSet,
MessageKey as MessageKey_defaultSet
} from "../messages_defaultSet/types";
import { type ReturnTypeOfCreateUseI18n, createUseI18n } from "../withJsx/useI18n";
export type I18nBuilder<
ThemeName extends string = never,
MessageKey_themeDefined extends string = never,
LanguageTag_notInDefaultSet extends string = never,
ExcludedMethod extends
| "withThemeName"
| "withExtraLanguages"
| "withCustomTranslations" = never
> = Omit<
{
withThemeName: <ThemeName extends string>() => I18nBuilder<
ThemeName,
MessageKey_themeDefined,
LanguageTag_notInDefaultSet,
ExcludedMethod | "withThemeName"
>;
withExtraLanguages: <
LanguageTag_notInDefaultSet extends string
>(extraLanguageTranslations: {
[LanguageTag in LanguageTag_notInDefaultSet]: {
label: string;
getMessages: () => Promise<{
default: Record<MessageKey_defaultSet, string>;
}>;
};
}) => I18nBuilder<
ThemeName,
MessageKey_themeDefined,
LanguageTag_notInDefaultSet,
ExcludedMethod | "withExtraLanguages"
>;
withCustomTranslations: <MessageKey_themeDefined extends string>(
messagesByLanguageTag_themeDefined: Partial<{
[LanguageTag in
| LanguageTag_defaultSet
| LanguageTag_notInDefaultSet]: Record<
MessageKey_themeDefined,
string | Record<ThemeName, string>
>;
}>
) => I18nBuilder<
ThemeName,
MessageKey_themeDefined,
LanguageTag_notInDefaultSet,
ExcludedMethod | "withCustomTranslations"
>;
build: () => ReturnTypeOfCreateUseI18n<
MessageKey_themeDefined,
LanguageTag_notInDefaultSet
>;
},
ExcludedMethod
>;
function createI18nBuilder<
ThemeName extends string = never,
MessageKey_themeDefined extends string = never,
LanguageTag_notInDefaultSet extends string = never
>(params: {
extraLanguageTranslations: {
[LanguageTag in LanguageTag_notInDefaultSet]: {
label: string;
getMessages: () => Promise<{
default: Record<MessageKey_defaultSet, string>;
}>;
};
};
messagesByLanguageTag_themeDefined: Partial<{
[LanguageTag in LanguageTag_defaultSet | LanguageTag_notInDefaultSet]: Record<
MessageKey_themeDefined,
string | Record<ThemeName, string>
>;
}>;
}): I18nBuilder<ThemeName, MessageKey_themeDefined, LanguageTag_notInDefaultSet> {
const i18nBuilder: I18nBuilder<
ThemeName,
MessageKey_themeDefined,
LanguageTag_notInDefaultSet
> = {
withThemeName: () =>
createI18nBuilder({
extraLanguageTranslations: params.extraLanguageTranslations,
messagesByLanguageTag_themeDefined:
params.messagesByLanguageTag_themeDefined as any
}),
withExtraLanguages: extraLanguageTranslations =>
createI18nBuilder({
extraLanguageTranslations,
messagesByLanguageTag_themeDefined:
params.messagesByLanguageTag_themeDefined as any
}),
withCustomTranslations: messagesByLanguageTag_themeDefined =>
createI18nBuilder({
extraLanguageTranslations: params.extraLanguageTranslations,
messagesByLanguageTag_themeDefined
}),
build: () =>
createUseI18n({
extraLanguageTranslations: params.extraLanguageTranslations,
messagesByLanguageTag_themeDefined:
params.messagesByLanguageTag_themeDefined
})
};
return i18nBuilder;
}
export const i18nBuilder = createI18nBuilder({
extraLanguageTranslations: {},
messagesByLanguageTag_themeDefined: {}
});

View File

@ -0,0 +1,3 @@
export { i18nBuilder } from "./i18nBuilder";
export type { KcContextLike } from "./useI18n";
export type { MessageKey as MessageKey_defaultSet } from "../messages_defaultSet/types";

View File

@ -1,17 +1,49 @@
import { useEffect, useState } from "react";
import { createGetI18n, type GenericI18n_noJsx, type KcContextLike, type MessageKey_defaultSet } from "./i18n";
import { GenericI18n } from "./GenericI18n";
import { kcSanitize } from "keycloakify/lib/kcSanitize";
import { createGetI18n, type KcContextLike } from "../noJsx/getI18n";
import type { GenericI18n_noJsx } from "../noJsx/GenericI18n_noJsx";
import { Reflect } from "tsafe/Reflect";
import type { GenericI18n } from "./GenericI18n";
import type { LanguageTag as LanguageTag_defaultSet, MessageKey as MessageKey_defaultSet } from "../messages_defaultSet/types";
export type ReturnTypeOfCreateUseI18n<MessageKey_themeDefined extends string, LanguageTag_notInDefaultSet extends string> = {
useI18n: (params: { kcContext: KcContextLike }) => {
i18n: GenericI18n<MessageKey_defaultSet | MessageKey_themeDefined, LanguageTag_defaultSet | LanguageTag_notInDefaultSet>;
};
ofTypeI18n: GenericI18n<MessageKey_defaultSet | MessageKey_themeDefined, LanguageTag_defaultSet | LanguageTag_notInDefaultSet>;
};
export { KcContextLike };
export function createUseI18n<
ThemeName extends string = string,
MessageKey_themeDefined extends string = never,
LanguageTag_notInDefaultSet extends string = never
>(params: {
extraLanguageTranslations: {
[languageTag in LanguageTag_notInDefaultSet]: {
label: string;
getMessages: () => Promise<{ default: Record<MessageKey_defaultSet, string> }>;
};
};
messagesByLanguageTag_themeDefined: Partial<{
[languageTag in LanguageTag_defaultSet | LanguageTag_notInDefaultSet]: {
[key in MessageKey_themeDefined]: string | Record<ThemeName, string>;
};
}>;
}): ReturnTypeOfCreateUseI18n<MessageKey_themeDefined, LanguageTag_notInDefaultSet> {
const { extraLanguageTranslations, messagesByLanguageTag_themeDefined } = params;
type LanguageTag = LanguageTag_defaultSet | LanguageTag_notInDefaultSet;
export function createUseI18n<MessageKey_themeDefined extends string = never>(messagesByLanguageTag: {
[languageTag: string]: { [key in MessageKey_themeDefined]: string };
}) {
type MessageKey = MessageKey_defaultSet | MessageKey_themeDefined;
type I18n = GenericI18n<MessageKey>;
type I18n = GenericI18n<MessageKey, LanguageTag>;
type Result = { i18n: I18n };
const { withJsx } = (() => {
const cache = new WeakMap<GenericI18n_noJsx<MessageKey>, GenericI18n<MessageKey>>();
const cache = new WeakMap<GenericI18n_noJsx<MessageKey, LanguageTag>, GenericI18n<MessageKey, LanguageTag>>();
function renderHtmlString(params: { htmlString: string; msgKey: string }): JSX.Element {
const { htmlString, msgKey } = params;
@ -19,13 +51,13 @@ export function createUseI18n<MessageKey_themeDefined extends string = never>(me
<div
data-kc-msg={msgKey}
dangerouslySetInnerHTML={{
__html: htmlString
__html: kcSanitize(htmlString)
}}
/>
);
}
function withJsx(i18n_noJsx: GenericI18n_noJsx<MessageKey>): I18n {
function withJsx(i18n_noJsx: GenericI18n_noJsx<MessageKey, LanguageTag>): I18n {
use_cache: {
const i18n = cache.get(i18n_noJsx);
@ -63,9 +95,9 @@ export function createUseI18n<MessageKey_themeDefined extends string = never>(me
(styleElement.textContent = `[data-kc-msg] { display: inline-block; }`), document.head.prepend(styleElement);
}
const { getI18n } = createGetI18n(messagesByLanguageTag);
const { getI18n } = createGetI18n({ extraLanguageTranslations, messagesByLanguageTag_themeDefined });
function useI18n(params: { kcContext: KcContextLike }): { i18n: I18n } {
function useI18n(params: { kcContext: KcContextLike }): Result {
const { kcContext } = params;
const { i18n, prI18n_currentLanguage } = getI18n({ kcContext });

View File

@ -1,3 +1,3 @@
export type { ExtendKcContext, Attribute } from "keycloakify/login/KcContext";
export type { ClassKey } from "keycloakify/login/TemplateProps";
export { createUseI18n } from "keycloakify/login/i18n";
export { i18nBuilder, type MessageKey_defaultSet } from "keycloakify/login/i18n";

View File

@ -1,5 +1,130 @@
import { createGetKcClsx } from "keycloakify/lib/getKcClsx";
import type { ClassKey } from "keycloakify/login/TemplateProps";
export type ClassKey =
| "kcBodyClass"
| "kcHeaderWrapperClass"
| "kcLocaleWrapperClass"
| "kcInfoAreaWrapperClass"
| "kcFormButtonsWrapperClass"
| "kcFormOptionsWrapperClass"
| "kcCheckboxInputClass"
| "kcLocaleDropDownClass"
| "kcLocaleListItemClass"
| "kcContentWrapperClass"
| "kcLogoIdP-facebook"
| "kcAuthenticatorOTPClass"
| "kcLogoIdP-bitbucket"
| "kcAuthenticatorWebAuthnClass"
| "kcWebAuthnDefaultIcon"
| "kcLogoIdP-stackoverflow"
| "kcSelectAuthListItemClass"
| "kcLogoIdP-microsoft"
| "kcLoginOTPListItemHeaderClass"
| "kcLocaleItemClass"
| "kcLoginOTPListItemIconBodyClass"
| "kcInputHelperTextAfterClass"
| "kcFormClass"
| "kcSelectAuthListClass"
| "kcInputClassRadioCheckboxLabelDisabled"
| "kcSelectAuthListItemIconClass"
| "kcRecoveryCodesWarning"
| "kcFormSettingClass"
| "kcWebAuthnBLE"
| "kcInputWrapperClass"
| "kcSelectAuthListItemArrowIconClass"
| "kcFeedbackAreaClass"
| "kcFormPasswordVisibilityButtonClass"
| "kcLogoIdP-google"
| "kcCheckLabelClass"
| "kcSelectAuthListItemFillClass"
| "kcAuthenticatorDefaultClass"
| "kcLogoIdP-gitlab"
| "kcFormAreaClass"
| "kcFormButtonsClass"
| "kcInputClassRadioLabel"
| "kcAuthenticatorWebAuthnPasswordlessClass"
| "kcSelectAuthListItemHeadingClass"
| "kcInfoAreaClass"
| "kcLogoLink"
| "kcContainerClass"
| "kcSelectAuthListItemTitle"
| "kcHtmlClass"
| "kcLoginOTPListItemTitleClass"
| "kcLogoIdP-openshift-v4"
| "kcWebAuthnUnknownIcon"
| "kcFormSocialAccountNameClass"
| "kcLogoIdP-openshift-v3"
| "kcLoginOTPListInputClass"
| "kcWebAuthnUSB"
| "kcInputClassRadio"
| "kcWebAuthnKeyIcon"
| "kcFeedbackInfoIcon"
| "kcCommonLogoIdP"
| "kcRecoveryCodesActions"
| "kcFormGroupHeader"
| "kcFormSocialAccountSectionClass"
| "kcLogoIdP-instagram"
| "kcAlertClass"
| "kcHeaderClass"
| "kcLabelWrapperClass"
| "kcFormPasswordVisibilityIconShow"
| "kcFormSocialAccountLinkClass"
| "kcLocaleMainClass"
| "kcInputGroup"
| "kcTextareaClass"
| "kcButtonBlockClass"
| "kcButtonClass"
| "kcWebAuthnNFC"
| "kcLocaleClass"
| "kcInputClassCheckboxInput"
| "kcFeedbackErrorIcon"
| "kcInputLargeClass"
| "kcInputErrorMessageClass"
| "kcRecoveryCodesList"
| "kcFormSocialAccountListClass"
| "kcAlertTitleClass"
| "kcAuthenticatorPasswordClass"
| "kcCheckInputClass"
| "kcLogoIdP-linkedin"
| "kcLogoIdP-twitter"
| "kcFeedbackWarningIcon"
| "kcResetFlowIcon"
| "kcSelectAuthListItemIconPropertyClass"
| "kcFeedbackSuccessIcon"
| "kcLoginOTPListClass"
| "kcSrOnlyClass"
| "kcFormSocialAccountListGridClass"
| "kcButtonDefaultClass"
| "kcFormGroupErrorClass"
| "kcSelectAuthListItemDescriptionClass"
| "kcSelectAuthListItemBodyClass"
| "kcWebAuthnInternal"
| "kcSelectAuthListItemArrowClass"
| "kcCheckClass"
| "kcContentClass"
| "kcLogoClass"
| "kcLoginOTPListItemIconClass"
| "kcLoginClass"
| "kcSignUpClass"
| "kcButtonLargeClass"
| "kcFormCardClass"
| "kcLocaleListClass"
| "kcInputClass"
| "kcFormGroupClass"
| "kcLogoIdP-paypal"
| "kcInputClassCheckbox"
| "kcRecoveryCodesConfirmation"
| "kcFormPasswordVisibilityIconHide"
| "kcInputClassRadioInput"
| "kcFormSocialAccountListButtonClass"
| "kcInputClassCheckboxLabel"
| "kcFormOptionsClass"
| "kcFormHeaderClass"
| "kcFormSocialAccountGridItem"
| "kcButtonPrimaryClass"
| "kcInputHelperTextBeforeClass"
| "kcLogoIdP-github"
| "kcLabelClass";
export const { getKcClsx } = createGetKcClsx<ClassKey>({
defaultClasses: {
@ -138,6 +263,4 @@ export const { getKcClsx } = createGetKcClsx<ClassKey>({
}
});
export type { ClassKey };
export type KcClsx = ReturnType<typeof getKcClsx>["kcClsx"];

View File

@ -3,6 +3,7 @@ import { useMemo, useReducer, useEffect, Fragment, type Dispatch } from "react";
import { assert, type Equals } from "tsafe/assert";
import { id } from "tsafe/id";
import { structuredCloneButFunctions } from "keycloakify/tools/structuredCloneButFunctions";
import { kcSanitize } from "keycloakify/lib/kcSanitize";
import { useConstCallback } from "keycloakify/tools/useConstCallback";
import { emailRegexp } from "keycloakify/tools/emailRegExp";
import { formatNumber } from "keycloakify/tools/formatNumber";
@ -10,7 +11,7 @@ import { useInsertScriptTags } from "keycloakify/tools/useInsertScriptTags";
import type { PasswordPolicies, Attribute, Validators } from "keycloakify/login/KcContext";
import type { KcContext } from "../KcContext";
import type { MessageKey_defaultSet } from "keycloakify/login/i18n";
import { KcContextLike as KcContextLike_i18n } from "keycloakify/login/i18n";
import type { KcContextLike as KcContextLike_i18n } from "keycloakify/login/i18n";
import type { I18n } from "../i18n";
export type FormFieldError = {
@ -661,7 +662,7 @@ function useGetErrors(params: { kcContext: KcContextLike_useGetErrors; i18n: I18
<span
key={0}
dangerouslySetInnerHTML={{
__html: errorMessageStr
__html: kcSanitize(errorMessageStr)
}}
/>
),

View File

@ -1,4 +1,5 @@
import type { PageProps } from "keycloakify/login/pages/PageProps";
import { kcSanitize } from "keycloakify/lib/kcSanitize";
import type { KcContext } from "../KcContext";
import type { I18n } from "../i18n";
@ -19,7 +20,7 @@ export default function Error(props: PageProps<Extract<KcContext, { pageId: "err
headerNode={msg("errorTitle")}
>
<div id="kc-error-message">
<p className="instruction" dangerouslySetInnerHTML={{ __html: message.summary }} />
<p className="instruction" dangerouslySetInnerHTML={{ __html: kcSanitize(message.summary) }} />
{!skipLink && client !== undefined && client.baseUrl !== undefined && (
<p>
<a id="backToApplication" href={client.baseUrl}>

View File

@ -1,4 +1,5 @@
import type { PageProps } from "keycloakify/login/pages/PageProps";
import { kcSanitize } from "keycloakify/lib/kcSanitize";
import type { KcContext } from "../KcContext";
import type { I18n } from "../i18n";
@ -19,7 +20,7 @@ export default function Info(props: PageProps<Extract<KcContext, { pageId: "info
headerNode={
<span
dangerouslySetInnerHTML={{
__html: messageHeader ?? message.summary
__html: kcSanitize(messageHeader ?? message.summary)
}}
/>
}
@ -28,19 +29,21 @@ export default function Info(props: PageProps<Extract<KcContext, { pageId: "info
<p
className="instruction"
dangerouslySetInnerHTML={{
__html: (() => {
let html = message.summary;
__html: kcSanitize(
(() => {
let html = message.summary;
if (requiredActions) {
html += "<b>";
if (requiredActions) {
html += "<b>";
html += requiredActions.map(requiredAction => advancedMsgStr(`requiredAction.${requiredAction}`)).join(", ");
html += requiredActions.map(requiredAction => advancedMsgStr(`requiredAction.${requiredAction}`)).join(", ");
html += "</b>";
}
html += "</b>";
}
return html;
})()
return html;
})()
)
}}
/>
{(() => {

View File

@ -1,4 +1,5 @@
import { useState, useEffect, useReducer } from "react";
import { kcSanitize } from "keycloakify/lib/kcSanitize";
import { assert } from "keycloakify/tools/assert";
import { clsx } from "keycloakify/tools/clsx";
import type { PageProps } from "keycloakify/login/pages/PageProps";
@ -43,7 +44,7 @@ export default function Login(props: PageProps<Extract<KcContext, { pageId: "log
}
socialProvidersNode={
<>
{realm.password && social.providers?.length && (
{realm.password && social?.providers !== undefined && social.providers.length !== 0 && (
<div id="kc-social-providers" className={kcClsx("kcFormSocialAccountSectionClass")}>
<hr />
<h2>{msg("identity-provider-login-label")}</h2>
@ -62,7 +63,7 @@ export default function Login(props: PageProps<Extract<KcContext, { pageId: "log
{p.iconClasses && <i className={clsx(kcClsx("kcCommonLogoIdP"), p.iconClasses)} aria-hidden="true"></i>}
<span
className={clsx(kcClsx("kcFormSocialAccountNameClass"), p.iconClasses && "kc-social-icon-text")}
dangerouslySetInnerHTML={{ __html: p.displayName }}
dangerouslySetInnerHTML={{ __html: kcSanitize(p.displayName) }}
></span>
</a>
</li>
@ -111,7 +112,7 @@ export default function Login(props: PageProps<Extract<KcContext, { pageId: "log
className={kcClsx("kcInputErrorMessageClass")}
aria-live="polite"
dangerouslySetInnerHTML={{
__html: messagesPerField.getFirstError("username", "password")
__html: kcSanitize(messagesPerField.getFirstError("username", "password"))
}}
/>
)}
@ -139,7 +140,7 @@ export default function Login(props: PageProps<Extract<KcContext, { pageId: "log
className={kcClsx("kcInputErrorMessageClass")}
aria-live="polite"
dangerouslySetInnerHTML={{
__html: messagesPerField.getFirstError("username", "password")
__html: kcSanitize(messagesPerField.getFirstError("username", "password"))
}}
/>
)}

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