Compare commits

..

146 Commits

Author SHA1 Message Date
8b5f7eefda Release candidate 2024-06-11 19:14:19 +02:00
c750bf4ee8 Export PageProps 2024-06-11 19:14:04 +02:00
aa74019ef6 Fix build 2024-06-11 19:08:36 +02:00
9be6d9f75f Release candidate 2024-06-11 17:27:40 +02:00
81ebb9b552 Prevent the jar to be corrupted when rebuild 2024-06-11 17:19:36 +02:00
5e13b8c41f Exclude Keycloak 22 from test panel 2024-06-11 17:12:12 +02:00
dd1ed948ec Update Keycloak 25 default realm config 2024-06-11 16:26:03 +02:00
8b93f701cf Add realms configurations for Keycloak majors 2024-06-11 16:19:54 +02:00
2f0084de5b Pass the input options translation to the kcContext 2024-06-11 16:10:54 +02:00
2ef9828625 Start with keycloak 18 for local container 2024-06-11 11:39:03 +02:00
89db8983a7 Fix exception in terms.ftl 2024-06-11 11:37:45 +02:00
287dd9bd31 Refactor + attributes with options rendered by default as select inputs 2024-06-11 09:22:50 +02:00
9a92054c1a Remove unused dependency 2024-06-10 21:06:02 +02:00
4189036213 Fix storybook 2024-06-10 21:05:17 +02:00
2c0a427ba5 Fix the script to export realm 2024-06-10 20:51:00 +02:00
77b488d624 Fix the formatNumber function 2024-06-10 20:14:14 +02:00
5249e05746 Release candidate 2024-06-10 19:36:11 +02:00
1e7a0dd7a6 Enable to add files to the jar with the post build options 2024-06-10 19:35:56 +02:00
fd67f2402a Release candidate 2024-06-10 17:30:20 +02:00
60a65ede2f Preserve ordering on user attributes 2024-06-10 17:30:00 +02:00
1fa659ce61 Release candidate 2024-06-10 16:01:56 +02:00
0ab903dbc7 Add new build target for Kc 25 https://github.com/p2-inc/keycloak-account-v1/pull/13 2024-06-10 15:29:08 +02:00
70b0a04793 Release candidate 2024-06-10 15:08:46 +02:00
c0df9aa939 Remove logs 2024-06-10 09:32:07 +02:00
60a1886942 Fix path error 2024-06-10 09:28:31 +02:00
1ebf97871b Fix logical error 2024-06-10 09:26:47 +02:00
72e321aa32 Fix update of the build process checkpoint 2024-06-10 09:24:16 +02:00
b0f602b565 Fix post build script 2024-06-10 09:12:24 +02:00
84c774503d Build rework checkpoint 2024-06-10 07:57:12 +02:00
9bbc7cc651 Release candidate 2024-06-09 15:04:47 +02:00
458083fb6d Prettier stable generated code 2024-06-09 15:04:31 +02:00
8dcfc840b4 Remove useless 'as const' 2024-06-09 14:34:41 +02:00
9d06a3a6ad Release candidate 2024-06-09 14:33:42 +02:00
86cd08b954 Add missing file to the NPM bundle 2024-06-09 14:33:29 +02:00
144c3cc082 Release candidate 2024-06-09 11:53:41 +02:00
802cef41a6 Rename KcApp to KcPage 2024-06-09 11:53:25 +02:00
e128e8f0a9 Release candidate 2024-06-09 11:25:05 +02:00
8a25b93ab2 Rename Fallback to DefaultPage 2024-06-09 11:24:50 +02:00
7a040935e9 i18n need to be passed as props if we want to be able to ovewrite 2024-06-09 11:20:45 +02:00
2015882688 Avoid loop rebuild in watch mode 2024-06-09 10:28:06 +02:00
379301eb9d Release candidate 2024-06-09 09:50:27 +02:00
5d86b05cdb Fix eject-page script 2024-06-09 09:50:02 +02:00
73c99d3157 Fix scripts 2024-06-09 09:39:16 +02:00
acba197c94 Release candidate 2024-06-09 09:36:31 +02:00
2441d8ed8a Fix tests 2024-06-09 09:36:16 +02:00
9c123f37c8 Make of doMakeUserConfirmPassword a prop of UserProfileFormFields 2024-06-09 09:34:39 +02:00
b48dbd99cf Enable to pass a path to a file for exclusions #525 2024-06-09 09:20:55 +02:00
25c8599d8f Rename BuildOptions -> BuildContext 2024-06-09 09:15:45 +02:00
3453a17c15 Rename reactAppRootDirPath -> projectDirPath and reactAppBuildDirPath -> projectBuildDirPath 2024-06-09 09:03:43 +02:00
6e95dacd3a Remove todo 2024-06-09 08:52:52 +02:00
a286e252e9 Enable to pass environement variables to test docker container 2024-06-09 08:50:59 +02:00
a8997e92c3 Improve cache strategy for getKcClsx 2024-06-09 08:37:23 +02:00
89137153a0 Remove isStorybook util 2024-06-09 08:37:02 +02:00
e3382de8e0 Fix boolean logic error 2024-06-09 08:30:57 +02:00
1a48681591 getClassName -> kcClsx 2024-06-09 08:27:07 +02:00
8f006f0009 Apply the new way i18n is implemented to every pages 2024-06-09 04:43:18 +02:00
77e32aad2a Make useGetClassName not a hook 2024-06-08 18:54:27 +02:00
8d365dae53 Refactor i18n, make component use the hook directly 2024-06-08 17:55:05 +02:00
01fb89674c Refactor i18n so that we don't have to wait for translations to be downloaded to render the page 2024-06-08 15:50:04 +02:00
e3144adc61 Release candidate 2024-06-08 14:21:20 +02:00
c9fb0ca6ae Rename extention types 2024-06-08 14:20:56 +02:00
82d7e1371e Add code gen for environement variables an theme name 2024-06-08 14:02:07 +02:00
e1341dfdba Release candidate 2024-06-08 07:17:29 +02:00
7f917311d8 Export ClassKey in the index instead of PageProps 2024-06-08 07:17:11 +02:00
2bfb856f07 Inconsistency fix 2024-06-08 04:10:04 +02:00
702f52f1c9 Only add the lang annotation if the lang is different from the current 2024-06-07 08:05:35 +02:00
7ba8649940 Release candidate 2024-06-07 08:03:28 +02:00
485ca28a29 Enable the lang of the term to be undefined 2024-06-07 08:03:13 +02:00
33460afaf2 Release candidate 2024-06-07 02:20:27 +02:00
2421ac2c11 Make the user return the actual language of the terms for accesibility 2024-06-07 02:20:12 +02:00
f0cdb0b80b Fix build 2024-06-06 09:31:27 +02:00
2af953927e Release candidate 2024-06-06 09:14:27 +02:00
dcb9fbd0f7 fixes for the add-story command 2024-06-06 09:13:58 +02:00
5bc1f6479d fmt 2024-06-06 09:13:13 +02:00
f3e4bca468 Add script to copy over the stories 2024-06-06 07:41:01 +02:00
54645f5cff Update storybook setup for portability 2024-06-06 07:28:34 +02:00
a7f3e00821 Remove createKcContextMock from the index 2024-06-06 06:27:28 +02:00
108c281b0c Enable to eject Template.tsx and UserProfileFormFields.tsx 2024-06-06 06:12:05 +02:00
58892cbb56 Change first level build target of bin 2024-06-06 06:11:34 +02:00
dae1053ca8 Consistency with the starter 2024-06-06 06:10:41 +02:00
83a9778c30 Release candidate 2024-06-06 04:36:16 +02:00
c52157bfb9 Remove dependency to powerhooks 2024-06-06 04:35:57 +02:00
62bf846d5f Release candidate 2024-06-06 02:29:25 +02:00
148f7fa316 Rollback unarrowing of the getKcContextMock return type 2024-06-06 02:29:09 +02:00
f488327885 Release candidate 2024-06-06 01:31:17 +02:00
593b929254 #525 2024-06-06 01:31:00 +02:00
9218e97315 Relase candidate 2024-06-06 00:33:51 +02:00
beb0e8bd77 Add missing translations 2024-06-06 00:33:34 +02:00
cace66e9f8 No longer need for a eslint rule ignore for with default vite starter 2024-06-05 23:30:28 +02:00
ef850c71fd Release candidate 2024-06-05 23:24:44 +02:00
aa8dc1919f Make getKcContext mock return type less narrow 2024-06-05 23:24:21 +02:00
c7c9b19853 Fix case error 2024-06-05 23:08:40 +02:00
68c26e0f5b Fix case issue 2024-06-05 22:55:09 +02:00
6bcdf286ef Add missing export 2024-06-05 22:53:14 +02:00
d9345396e8 Fix build 2024-06-05 22:48:13 +02:00
4c423900d4 Release candidate 2024-06-05 21:44:54 +02:00
504419b26d Fix storybook 2024-06-05 21:44:26 +02:00
6e058eafed It's less confusing to use "#" as urls in mock kcContext 2024-06-05 21:44:14 +02:00
08fc9d8631 Fix tests by updating vitest 2024-06-05 21:23:34 +02:00
e8a11991a0 Rename kcContext -> KcContext and improve consistency 2024-06-05 21:13:58 +02:00
3e6d679838 Release candidate 2024-06-05 18:50:26 +02:00
4dad859c4d Fix storybook 2024-06-05 18:50:00 +02:00
ef9c933ca8 Relase candidate 2024-06-05 18:42:50 +02:00
0461190a67 Do not export default the Fallback component 2024-06-05 18:42:32 +02:00
06b3211b08 Ease up the instentiation of i18n 2024-06-05 18:41:53 +02:00
2033a9ce0c Release candidate 2024-06-05 18:15:25 +02:00
fca18d9209 Add missing file to the NPM bundle 2024-06-05 18:15:13 +02:00
4f99088449 Release candidate 2024-06-05 06:11:18 +02:00
b1da684008 Re implement asset fetching 2024-06-05 06:10:11 +02:00
89fb6de2d5 Full ordering of stories 2024-06-05 06:09:42 +02:00
b665bae3bb Another improvement on storybook switching from one page to another 2024-06-05 01:32:31 +02:00
0b5a7544ca Address white falshes in storybook 2024-06-05 01:02:17 +02:00
183826ca0d Improve terms story 2024-06-04 04:06:29 +02:00
e507aace6b Change ordering of stories 2024-06-04 04:06:09 +02:00
43c93ef0b4 Update the intro story 2024-06-04 02:05:09 +02:00
093e51e092 Fix escaping error 2024-06-04 01:49:26 +02:00
17e1655eaf Fix recaptcha in storybook 2024-06-04 01:39:54 +02:00
6b570f2b9a Update register story 2024-06-03 23:54:08 +02:00
f239d105a7 Fix missing key 2024-06-03 23:53:53 +02:00
776d8378e3 Shorter white flash when changing stories 2024-06-03 23:40:33 +02:00
dd770cd7c6 Remove unessesary stories 2024-06-03 23:40:21 +02:00
4b3de54e18 Make it more conveignent to run storybook 2024-06-03 23:26:04 +02:00
5741cd1b2b Lower the priority of the without password story. 2024-06-03 23:25:37 +02:00
b780d7136e Fix mistake after using attributesByName instead of attributes 2024-06-03 23:25:02 +02:00
3c28a05746 Fix copy-keycloak-resources-to-public 2024-06-03 22:45:09 +02:00
57ac5badba Update the euristic for getting the NPM workspace root. 2024-06-03 22:37:22 +02:00
e873eb5123 Rollback typescript because updating storybook would add a one month delay to the release 2024-06-03 22:36:54 +02:00
c1a63edd71 Refactor kcContext, avoid having mocks in the dist https://github.com/keycloakify/keycloakify/discussions/299#discussioncomment-9616747 2024-06-03 18:28:34 +02:00
37a060c4db Change ordering of pages 2024-06-03 01:23:41 +02:00
157e4ac485 Add missing storybook pages 2024-06-03 01:23:28 +02:00
ba4d9675a8 More homogeneous storybook setup 2024-06-03 00:11:19 +02:00
e011fb094c Factorize parameters in storybook 2024-06-02 22:37:04 +02:00
f55a934939 Complete migration of storybook from @lordvlad #274 2024-06-02 22:29:53 +02:00
96a88fe865 Fix add remove button for multifield attributes 2024-06-02 00:31:08 +02:00
6cdb83d730 Fix the way we handle multivalued single fileld (multiselct, multiselect-checkboxes) 2024-06-02 00:24:07 +02:00
95f06df45d Extenalize some core logic from the ejectable component 2024-06-01 22:54:17 +02:00
ec52b357d5 Fix logical error with radibuttons 2024-05-30 23:23:16 +02:00
d84546cd7d Correct error validation password policy 2024-05-30 22:50:06 +02:00
4eee4156da Release candidate 2024-05-28 01:27:01 +02:00
70f475d13e Remove some more noise in the kcContext 2024-05-28 01:26:33 +02:00
3a50a61b12 First test against the key for faster ftl rendering 2024-05-28 01:08:02 +02:00
a217f617d8 Remove profile.attributesByName from the kcContext 2024-05-28 01:05:35 +02:00
fdfcd78f02 Watches more files that are relevent to the keycloak theme 2024-05-28 00:55:46 +02:00
56d6d8001a Fix #549 after test 2024-05-28 00:23:48 +02:00
c3ee8e10e6 Release candidate 2024-05-27 23:45:07 +02:00
2f42732deb #549 Done 2024-05-27 23:44:41 +02:00
230 changed files with 18377 additions and 6882 deletions

View File

@ -34,7 +34,6 @@ export function DocsContainer({ children, context }) {
.docblock-argstable-head th:nth-child(3), .docblock-argstable-body tr > td:nth-child(2) p {
font-size: 13px;
}
`}</style>
<BaseContainer
context={{
@ -64,11 +63,6 @@ export function CanvasContainer({ children }) {
return (
<>
<style>{`
body {
padding: 0 !important;
}
`}</style>
{children}
</>
);

View File

@ -0,0 +1,17 @@
<style>
body.sb-show-main.sb-main-padded {
padding: 0;
}
body:not(.kcBodyClass) {
background-color: #393939;
}
body.sb-show-preparing-docs > .sb-wrapper {
visibility: hidden;
}
body .sb-preparing-story {
visibility: hidden;
}
</style>

View File

@ -116,10 +116,45 @@ const { getHardCodedWeight } = (() => {
const orderedPagesPrefix = [
"Introduction",
"login/login.ftl",
"login/register-user-profile.ftl",
"login/register.ftl",
"login/terms.ftl",
"login/error.ftl",
"login/code.ftl",
"login/delete-account-confirm.ftl",
"login/delete-credential.ftl",
"login/frontchannel-logout.ftl",
"login/idp-review-user-profile.ftl",
"login/info.ftl",
"login/login-config-totp.ftl",
"login/login-idp-link-confirm.ftl",
"login/login-idp-link-email.ftl",
"login/login-oauth-grant.ftl",
"login/login-otp.ftl",
"login/login-page-expired.ftl",
"login/login-password.ftl",
"login/login-reset-otp.ftl",
"login/login-reset-password.ftl",
"login/login-update-password.ftl",
"login/login-update-profile.ftl",
"login/login-username.ftl",
"login/login-verify-email.ftl",
"login/login-x509-info.ftl",
"login/logout-confirm.ftl",
"login/saml-post-form.ftl",
"login/select-authenticator.ftl",
"login/update-email.ftl",
"login/webauthn-authenticate.ftl",
"login/webauthn-error.ftl",
"login/webauthn-register.ftl",
"login/login-oauth2-device-verify-user-code.ftl",
"login/login-recovery-authn-code-config.ftl",
"login/login-recovery-authn-code-input.ftl",
"account/account.ftl",
"account/password.ftl",
"account/federatedIdentity.ftl",
"account/log.ftl",
"account/sessions.ftl",
"account/totp.ftl",
];
function getHardCodedWeight(kind) {

View File

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

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

@ -0,0 +1,49 @@
## 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,6 +1,6 @@
{
"name": "keycloakify",
"version": "10.0.0-rc.21",
"version": "10.0.0-rc.50",
"description": "Create Keycloak themes using React",
"repository": {
"type": "git",
@ -9,7 +9,7 @@
"scripts": {
"prepare": "patch-package && ts-node --skipProject scripts/generate-i18n-messages.ts",
"build": "ts-node --skipProject scripts/build.ts",
"storybook": "yarn build && yarn copy-keycloak-resources-to-storybook-static && start-storybook -p 6006",
"storybook": "ts-node --skipProject scripts/start-storybook.ts",
"link-in-starter": "ts-node --skipProject scripts/link-in-starter.ts",
"test": "yarn test:types && vitest run",
"test:types": "tsc -p test/tsconfig.json --noEmit",
@ -17,8 +17,7 @@
"format": "yarn _format --write",
"format:check": "yarn _format --list-different",
"link-in-app": "ts-node --skipProject scripts/link-in-app.ts",
"copy-keycloak-resources-to-storybook-static": "PUBLIC_DIR_PATH=.storybook/static node dist/bin/main.js copy-keycloak-resources-to-public",
"build-storybook": "yarn build && yarn copy-keycloak-resources-to-storybook-static && build-storybook",
"build-storybook": "ts-node --skipProject scripts/build-storybook.ts",
"dump-keycloak-realm": "ts-node --skipProject scripts/dump-keycloak-realm.ts"
},
"bin": {
@ -45,8 +44,10 @@
"dist/bin/shared/constants.js",
"dist/bin/shared/constants.d.ts",
"dist/bin/shared/constants.js.map",
"dist/bin/shared/buildContext.d.ts",
"!dist/vite-plugin/",
"dist/vite-plugin/index.d.ts",
"dist/vite-plugin/vite-plugin.d.ts",
"dist/vite-plugin/index.js"
],
"keywords": [
@ -65,7 +66,6 @@
"react": "*"
},
"dependencies": {
"minimal-polyfills": "^2.2.3",
"react-markdown": "^5.0.3",
"tsafe": "^1.6.6"
},
@ -110,15 +110,14 @@
"react-dom": "^18.2.0",
"recast": "^0.23.3",
"run-exclusive": "^2.2.19",
"scripting-tools": "^0.19.13",
"storybook-dark-mode": "^1.1.2",
"termost": "^0.12.0",
"ts-node": "^10.9.2",
"tsc-alias": "^1.8.10",
"tss-react": "^4.9.10",
"typescript": "^5.4.5",
"typescript": "^4.9.1-beta",
"vite": "^5.2.11",
"vitest": "^0.29.8",
"vitest": "^1.6.0",
"yauzl": "^2.10.0",
"zod": "^3.17.10",
"evt": "^2.5.7"

View File

@ -0,0 +1,19 @@
import * as child_process from "child_process";
import { join } from "path";
run("yarn build");
run(`node ${join("dist", "bin", "main.js")} copy-keycloak-resources-to-public`, {
env: {
...process.env,
PUBLIC_DIR_PATH: join(".storybook", "static")
}
});
run("npx build-storybook");
function run(command: string, options?: { env?: NodeJS.ProcessEnv }) {
console.log(`$ ${command}`);
child_process.execSync(command, { stdio: "inherit", ...options });
}

View File

@ -52,7 +52,25 @@ transformCodebase({
fs.rmSync(join("dist", "ncc_out"), { recursive: true });
patchDeprecatedBufferApiUsage(join("dist", "bin", "main.js"));
{
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"),
@ -93,6 +111,10 @@ run(
)}`
);
fs.readdirSync(join("dist", "ncc_out")).forEach(fileBasename =>
assert(!fileBasename.endsWith(".index.js"))
);
transformCodebase({
srcDirPath: join("dist", "ncc_out"),
destDirPath: join("dist", "vite-plugin"),
@ -105,12 +127,30 @@ transformCodebase({
fs.rmSync(join("dist", "ncc_out"), { recursive: true });
patchDeprecatedBufferApiUsage(join("dist", "vite-plugin", "index.js"));
{
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) {
@ -127,7 +167,9 @@ function patchDeprecatedBufferApiUsage(filePath: string) {
`var buffer = Buffer.allocUnsafe ? Buffer.allocUnsafe(toRead) : new Buffer(toRead);`
);
assert(after !== before, `Patch failed for ${relative(process.cwd(), filePath)}`);
fs.writeFileSync(filePath, Buffer.from(after, "utf8"));
const hasBeenPatched = after !== before;
return { hasBeenPatched };
}

View File

@ -3,40 +3,83 @@ import child_process from "child_process";
import { SemVer } from "../src/bin/tools/SemVer";
import { join as pathJoin, relative as pathRelative } from "path";
import chalk from "chalk";
import { Deferred } from "evt/tools/Deferred";
import { assert } from "tsafe/assert";
import { is } from "tsafe/is";
run(
[
`docker exec -it ${containerName}`,
`/opt/keycloak/bin/kc.sh export`,
`--dir /tmp`,
`--realm myrealm`,
`--users realm_file`
].join(" ")
);
(async () => {
{
const dCompleted = new Deferred<void>();
const keycloakMajorVersionNumber = SemVer.parse(
child_process
.execSync(`docker inspect --format '{{.Config.Image}}' ${containerName}`)
.toString("utf8")
.trim()
.split(":")[1]
).major;
const child = child_process.spawn("docker", [
...["exec", containerName],
...["/opt/keycloak/bin/kc.sh", "export"],
...["--dir", "/tmp"],
...["--realm", "myrealm"],
...["--users", "realm_file"]
]);
const targetFilePath = pathRelative(
process.cwd(),
pathJoin(
__dirname,
"..",
"src",
"bin",
"start-keycloak",
`myrealm-realm-${keycloakMajorVersionNumber}.json`
)
);
let output = "";
run(`docker cp ${containerName}:/tmp/myrealm-realm.json ${targetFilePath}`);
const onExit = (code: number | null) => {
dCompleted.reject(new Error(`Exited with code ${code}`));
};
console.log(`${chalk.green(`✓ Exported realm to`)} ${chalk.bold(targetFilePath)}`);
child.on("exit", onExit);
child.stdout.on("data", data => {
const outputStr = data.toString("utf8");
if (outputStr.includes("Export finished successfully")) {
child.removeListener("exit", onExit);
child.kill();
dCompleted.resolve();
}
output += outputStr;
});
child.stderr.on("data", data => (output += chalk.red(data.toString("utf8"))));
try {
await dCompleted.pr;
} catch (error) {
assert(is<Error>(error));
console.log(chalk.red(error.message));
console.log(output);
process.exit(1);
}
}
const keycloakMajorVersionNumber = SemVer.parse(
child_process
.execSync(`docker inspect --format '{{.Config.Image}}' ${containerName}`)
.toString("utf8")
.trim()
.split(":")[1]
).major;
const targetFilePath = pathRelative(
process.cwd(),
pathJoin(
__dirname,
"..",
"src",
"bin",
"start-keycloak",
`myrealm-realm-${keycloakMajorVersionNumber}.json`
)
);
run(`docker cp ${containerName}:/tmp/myrealm-realm.json ${targetFilePath}`);
console.log(`${chalk.green(`✓ Exported realm to`)} ${chalk.bold(targetFilePath)}`);
})();
function run(command: string) {
console.log(chalk.grey(`$ ${command}`));

View File

@ -6,10 +6,12 @@ import {
dirname as pathDirname,
sep as pathSep
} from "path";
import { assert } from "tsafe/assert";
import { same } from "evt/tools/inDepth";
import { crawl } from "../src/bin/tools/crawl";
import { downloadKeycloakDefaultTheme } from "../src/bin/shared/downloadKeycloakDefaultTheme";
import { getThisCodebaseRootDirPath } from "../src/bin/tools/getThisCodebaseRootDirPath";
import { rmSync } from "../src/bin/tools/fs.rmSync";
import { deepAssign } from "../src/tools/deepAssign";
// NOTE: To run without argument when we want to generate src/i18n/generated_kcMessages files,
// update the version array for generating for newer version.
@ -24,7 +26,7 @@ async function main() {
const { defaultThemeDirPath } = await downloadKeycloakDefaultTheme({
keycloakVersion,
buildOptions: {
buildContext: {
cacheDirPath: pathJoin(
thisCodebaseRootDirPath,
"node_modules",
@ -73,12 +75,35 @@ async function main() {
}
Object.keys(record).forEach(themeType => {
const recordForPageType = record[themeType];
if (themeType !== "login" && themeType !== "account") {
return;
}
const recordForThemeType = record[themeType];
const languages = Object.keys(recordForThemeType);
const keycloakifyExtraMessages = (() => {
switch (themeType) {
case "login":
return keycloakifyExtraMessages_login;
case "account":
return keycloakifyExtraMessages_account;
}
assert(false);
})();
assert(
same(languages, Object.keys(keycloakifyExtraMessages), {
takeIntoAccountArraysOrdering: false
})
);
deepAssign({
target: recordForThemeType,
source: keycloakifyExtraMessages
});
const baseMessagesDirPath = pathJoin(
thisCodebaseRootDirPath,
"src",
@ -87,8 +112,6 @@ async function main() {
"baseMessages"
);
const languages = Object.keys(recordForPageType);
const generatedFileHeader = [
`//This code was automatically generated by running ${pathRelative(
thisCodebaseRootDirPath,
@ -110,7 +133,7 @@ async function main() {
"",
"/* spell-checker: disable */",
`const messages= ${JSON.stringify(
recordForPageType[language],
recordForThemeType[language],
null,
2
)};`,
@ -154,6 +177,491 @@ async function main() {
});
}
const keycloakifyExtraMessages_login: 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",
Record<
| "shouldBeEqual"
| "shouldBeDifferent"
| "shouldMatchPattern"
| "mustBeAnInteger"
| "notAValidOption"
| "selectAnOption"
| "remove"
| "addValue"
| "languages",
string
>
> = {
en: {
shouldBeEqual: "{0} should be equal to {1}",
shouldBeDifferent: "{0} should be different to {1}",
shouldMatchPattern: "Pattern should match: `/{0}/`",
mustBeAnInteger: "Must be an integer",
notAValidOption: "Not a valid option",
selectAnOption: "Select an option",
remove: "Remove",
addValue: "Add value",
languages: "Languages"
},
/* spell-checker: disable */
ar: {
shouldBeEqual: "{0} يجب أن يكون مساويًا لـ {1}",
shouldBeDifferent: "{0} يجب أن يكون مختلفًا عن {1}",
shouldMatchPattern: "`/يجب أن يطابق النمط: `/{0}/",
mustBeAnInteger: "يجب أن يكون عددًا صحيحًا",
notAValidOption: "ليس خيارًا صالحًا",
selectAnOption: "اختر خيارًا",
remove: "إزالة",
addValue: "أضف قيمة",
languages: "اللغات"
},
ca: {
shouldBeEqual: "{0} hauria de ser igual a {1}",
shouldBeDifferent: "{0} hauria de ser diferent de {1}",
shouldMatchPattern: "El patró hauria de coincidir: `/{0}/`",
mustBeAnInteger: "Ha de ser un enter",
notAValidOption: "No és una opció vàlida",
selectAnOption: "Selecciona una opció",
remove: "Elimina",
addValue: "Afegeix valor",
languages: "Idiomes"
},
cs: {
shouldBeEqual: "{0} by měl být roven {1}",
shouldBeDifferent: "{0} by měl být odlišný od {1}",
shouldMatchPattern: "Vzor by měl odpovídat: `/{0}/`",
mustBeAnInteger: "Musí být celé číslo",
notAValidOption: "Není platná možnost",
selectAnOption: "Vyberte možnost",
remove: "Odstranit",
addValue: "Přidat hodnotu",
languages: "Jazyky"
},
da: {
shouldBeEqual: "{0} bør være lig med {1}",
shouldBeDifferent: "{0} bør være forskellig fra {1}",
shouldMatchPattern: "Mønsteret bør matche: `/{0}/`",
mustBeAnInteger: "Skal være et heltal",
notAValidOption: "Ikke en gyldig mulighed",
selectAnOption: "Vælg en mulighed",
remove: "Fjern",
addValue: "Tilføj værdi",
languages: "Sprog"
},
de: {
shouldBeEqual: "{0} sollte gleich {1} sein",
shouldBeDifferent: "{0} sollte sich von {1} unterscheiden",
shouldMatchPattern: "Muster sollte übereinstimmen: `/{0}/`",
mustBeAnInteger: "Muss eine ganze Zahl sein",
notAValidOption: "Keine gültige Option",
selectAnOption: "Wählen Sie eine Option",
remove: "Entfernen",
addValue: "Wert hinzufügen",
languages: "Sprachen"
},
el: {
shouldBeEqual: "Το {0} πρέπει να είναι ίσο με {1}",
shouldBeDifferent: "Το {0} πρέπει να διαφέρει από το {1}",
shouldMatchPattern: "Το πρότυπο πρέπει να ταιριάζει: `/{0}/`",
mustBeAnInteger: "Πρέπει να είναι ακέραιος",
notAValidOption: "Δεν είναι μια έγκυρη επιλογή",
selectAnOption: "Επιλέξτε μια επιλογή",
remove: "Αφαίρεση",
addValue: "Προσθήκη τιμής",
languages: "Γλώσσες"
},
es: {
shouldBeEqual: "{0} debería ser igual a {1}",
shouldBeDifferent: "{0} debería ser diferente a {1}",
shouldMatchPattern: "El patrón debería coincidir: `/{0}/`",
mustBeAnInteger: "Debe ser un número entero",
notAValidOption: "No es una opción válida",
selectAnOption: "Selecciona una opción",
remove: "Eliminar",
addValue: "Añadir valor",
languages: "Idiomas"
},
fa: {
shouldBeEqual: "{0} باید برابر باشد با {1}",
shouldBeDifferent: "{0} باید متفاوت باشد از {1}",
shouldMatchPattern: "الگو باید مطابقت داشته باشد: `/{0}/`",
mustBeAnInteger: "باید یک عدد صحیح باشد",
notAValidOption: "یک گزینه معتبر نیست",
selectAnOption: "یک گزینه انتخاب کنید",
remove: "حذف",
addValue: "افزودن مقدار",
languages: "زبان‌ها"
},
fi: {
shouldBeEqual: "{0} pitäisi olla yhtä suuri kuin {1}",
shouldBeDifferent: "{0} pitäisi olla erilainen kuin {1}",
shouldMatchPattern: "Mallin tulisi vastata: `/{0}/`",
mustBeAnInteger: "On oltava kokonaisluku",
notAValidOption: "Ei ole kelvollinen vaihtoehto",
selectAnOption: "Valitse vaihtoehto",
remove: "Poista",
addValue: "Lisää arvo",
languages: "Kielet"
},
fr: {
shouldBeEqual: "{0} devrait être égal à {1}",
shouldBeDifferent: "{0} devrait être différent de {1}",
shouldMatchPattern: "Le motif devrait correspondre: `/{0}/`",
mustBeAnInteger: "Doit être un entier",
notAValidOption: "Pas une option valide",
selectAnOption: "Sélectionnez une option",
remove: "Supprimer",
addValue: "Ajouter une valeur",
languages: "Langues"
},
hu: {
shouldBeEqual: "{0} egyenlő kell legyen {1}-vel",
shouldBeDifferent: "{0} különbözőnek kell lennie, mint {1}",
shouldMatchPattern: "A mintának egyeznie kell: `/{0}/`",
mustBeAnInteger: "Egész számnak kell lennie",
notAValidOption: "Nem érvényes opció",
selectAnOption: "Válasszon egy lehetőséget",
remove: "Eltávolítás",
addValue: "Érték hozzáadása",
languages: "Nyelvek"
},
it: {
shouldBeEqual: "{0} dovrebbe essere uguale a {1}",
shouldBeDifferent: "{0} dovrebbe essere diverso da {1}",
shouldMatchPattern: "Il modello dovrebbe corrispondere: `/{0}/`",
mustBeAnInteger: "Deve essere un numero intero",
notAValidOption: "Non è un'opzione valida",
selectAnOption: "Seleziona un'opzione",
remove: "Rimuovi",
addValue: "Aggiungi valore",
languages: "Lingue"
},
ja: {
shouldBeEqual: "{0} は {1} と等しい必要があります",
shouldBeDifferent: "{0} は {1} と異なる必要があります",
shouldMatchPattern: "パターンは一致する必要があります: `/{0}/`",
mustBeAnInteger: "整数である必要があります",
notAValidOption: "有効なオプションではありません",
selectAnOption: "オプションを選択",
remove: "削除",
addValue: "値を追加",
languages: "言語"
},
lt: {
shouldBeEqual: "{0} turėtų būti lygus {1}",
shouldBeDifferent: "{0} turėtų skirtis nuo {1}",
shouldMatchPattern: "Šablonas turėtų atitikti: `/{0}/`",
mustBeAnInteger: "Turi būti sveikasis skaičius",
notAValidOption: "Netinkama parinktis",
selectAnOption: "Pasirinkite parinktį",
remove: "Pašalinti",
addValue: "Pridėti reikšmę",
languages: "Kalbos"
},
lv: {
shouldBeEqual: "{0} jābūt vienādam ar {1}",
shouldBeDifferent: "{0} jābūt atšķirīgam no {1}",
shouldMatchPattern: "Mustrim jāsakrīt: `/{0}/`",
mustBeAnInteger: "Jābūt veselam skaitlim",
notAValidOption: "Nav derīga opcija",
selectAnOption: "Izvēlieties opciju",
remove: "Noņemt",
addValue: "Pievienot vērtību",
languages: "Valodas"
},
nl: {
shouldBeEqual: "{0} moet gelijk zijn aan {1}",
shouldBeDifferent: "{0} moet verschillen van {1}",
shouldMatchPattern: "Patroon moet overeenkomen: `/{0}/`",
mustBeAnInteger: "Moet een geheel getal zijn",
notAValidOption: "Geen geldige optie",
selectAnOption: "Selecteer een optie",
remove: "Verwijderen",
addValue: "Waarde toevoegen",
languages: "Talen"
},
no: {
shouldBeEqual: "{0} skal være lik {1}",
shouldBeDifferent: "{0} skal være forskjellig fra {1}",
shouldMatchPattern: "Mønsteret skal matche: `/{0}/`",
mustBeAnInteger: "Må være et heltall",
notAValidOption: "Ikke et gyldig alternativ",
selectAnOption: "Velg et alternativ",
remove: "Fjern",
addValue: "Legg til verdi",
languages: "Språk"
},
pl: {
shouldBeEqual: "{0} powinno być równe {1}",
shouldBeDifferent: "{0} powinno być różne od {1}",
shouldMatchPattern: "Wzór pow inien pasować: `/{0}/`",
mustBeAnInteger: "Musi być liczbą całkowitą",
notAValidOption: "Nieprawidłowa opcja",
selectAnOption: "Wybierz opcję",
remove: "Usuń",
addValue: "Dodaj wartość",
languages: "Języki"
},
"pt-BR": {
shouldBeEqual: "{0} deve ser igual a {1}",
shouldBeDifferent: "{0} deve ser diferente de {1}",
shouldMatchPattern: "O padrão deve corresponder: `/{0}/`",
mustBeAnInteger: "Deve ser um número inteiro",
notAValidOption: "Não é uma opção válida",
selectAnOption: "Selecione uma opção",
remove: "Remover",
addValue: "Adicionar valor",
languages: "Idiomas"
},
ru: {
shouldBeEqual: "{0} должно быть равно {1}",
shouldBeDifferent: "{0} должно отличаться от {1}",
shouldMatchPattern: "Шаблон должен соответствовать: `/{0}/`",
mustBeAnInteger: "Должно быть целым числом",
notAValidOption: "Недопустимый вариант",
selectAnOption: "Выберите вариант",
remove: "Удалить",
addValue: "Добавить значение",
languages: "Языки"
},
sk: {
shouldBeEqual: "{0} by mal byť rovnaký ako {1}",
shouldBeDifferent: "{0} by mal byť odlišný od {1}",
shouldMatchPattern: "Vzor by mal zodpovedať: `/{0}/`",
mustBeAnInteger: "Musí byť celé číslo",
notAValidOption: "Nie je platná možnosť",
selectAnOption: "Vyberte možnosť",
remove: "Odstrániť",
addValue: "Pridať hodnotu",
languages: "Jazyky"
},
sv: {
shouldBeEqual: "{0} bör vara lika med {1}",
shouldBeDifferent: "{0} bör vara annorlunda än {1}",
shouldMatchPattern: "Mönstret bör matcha: `/{0}/`",
mustBeAnInteger: "Måste vara ett heltal",
notAValidOption: "Inte ett giltigt alternativ",
selectAnOption: "Välj ett alternativ",
remove: "Ta bort",
addValue: "Lägg till värde",
languages: "Språk"
},
th: {
shouldBeEqual: "{0} ควรเท่ากับ {1}",
shouldBeDifferent: "{0} ควรแตกต่างจาก {1}",
shouldMatchPattern: "รูปแบบควรตรงกับ: `/{0}/`",
mustBeAnInteger: "ต้องเป็นจำนวนเต็ม",
notAValidOption: "ไม่ใช่ตัวเลือกที่ถูกต้อง",
selectAnOption: "เลือกตัวเลือก",
remove: "ลบ",
addValue: "เพิ่มค่า",
languages: "ภาษา"
},
tr: {
shouldBeEqual: "{0} {1} eşit olmalıdır",
shouldBeDifferent: "{0} {1} farklı olmalıdır",
shouldMatchPattern: "Desen eşleşmelidir: `/{0}/`",
mustBeAnInteger: "Tam sayı olmalıdır",
notAValidOption: "Geçerli bir seçenek değil",
selectAnOption: "Bir seçenek seçin",
remove: "Kaldır",
addValue: "Değer ekle",
languages: "Diller"
},
uk: {
shouldBeEqual: "{0} повинно бути рівним {1}",
shouldBeDifferent: "{0} повинно відрізнятися від {1}",
shouldMatchPattern: "Шаблон повинен відповідати: `/{0}/`",
mustBeAnInteger: "Повинно бути цілим числом",
notAValidOption: "Не є дійсною опцією",
selectAnOption: "Виберіть опцію",
remove: "Видалити",
addValue: "Додати значення",
languages: "Мови"
},
"zh-CN": {
shouldBeEqual: "{0} 应该等于 {1}",
shouldBeDifferent: "{0} 应该不同于 {1}",
shouldMatchPattern: "模式应匹配: `/{0}/`",
mustBeAnInteger: "必须是整数",
notAValidOption: "不是有效选项",
selectAnOption: "选择一个选项",
remove: "移除",
addValue: "添加值",
languages: "语言"
}
/* spell-checker: enable */
};
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",
Record<"newPasswordSameAsOld" | "passwordConfirmNotMatch", string>
> = {
en: {
newPasswordSameAsOld: "New password must be different from the old one",
passwordConfirmNotMatch: "Password confirmation does not match"
},
/* spell-checker: disable */
ar: {
newPasswordSameAsOld: "يجب أن تكون كلمة المرور الجديدة مختلفة عن القديمة",
passwordConfirmNotMatch: "تأكيد كلمة المرور لا يتطابق"
},
ca: {
newPasswordSameAsOld: "La nova contrasenya ha de ser diferent de l'anterior",
passwordConfirmNotMatch: "La confirmació de la contrasenya no coincideix"
},
cs: {
newPasswordSameAsOld: "Nové heslo musí být odlišné od starého",
passwordConfirmNotMatch: "Potvrzení hesla se neshoduje"
},
da: {
newPasswordSameAsOld: "Det nye kodeord skal være forskelligt fra det gamle",
passwordConfirmNotMatch: "Adgangskodebekræftelse matcher ikke"
},
de: {
newPasswordSameAsOld: "Das neue Passwort muss sich vom alten unterscheiden",
passwordConfirmNotMatch: "Passwortbestätigung stimmt nicht überein"
},
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ää"
},
fr: {
newPasswordSameAsOld: "Le nouveau mot de passe doit être différent de l'ancien",
passwordConfirmNotMatch: "La confirmation du mot de passe ne correspond pas"
},
hu: {
newPasswordSameAsOld: "Az új jelszónak különböznie kell az előzőtől",
passwordConfirmNotMatch: "A jelszó megerősítése nem egyezik"
},
it: {
newPasswordSameAsOld:
"La nuova password deve essere diversa da quella precedente",
passwordConfirmNotMatch: "La conferma della password non corrisponde"
},
ja: {
newPasswordSameAsOld: "新しいパスワードは古いパスワードと異なる必要があります",
passwordConfirmNotMatch: "パスワード確認が一致しません"
},
lt: {
newPasswordSameAsOld: "Naujas slaptažodis turi skirtis nuo seno",
passwordConfirmNotMatch: "Slaptažodžio patvirtinimas neatitinka"
},
lv: {
newPasswordSameAsOld: "Jaunajam parolam jābūt atšķirīgam no vecā",
passwordConfirmNotMatch: "Paroles apstiprināšana neatbilst"
},
nl: {
newPasswordSameAsOld: "Het nieuwe wachtwoord moet verschillend zijn van het oude",
passwordConfirmNotMatch: "Wachtwoordbevestiging komt niet overeen"
},
no: {
newPasswordSameAsOld: "Det nye passordet må være forskjellig fra det gamle",
passwordConfirmNotMatch: "Passordbekreftelsen stemmer ikke"
},
pl: {
newPasswordSameAsOld: "Nowe hasło musi być inne niż stare",
passwordConfirmNotMatch: "Potwierdzenie hasła nie pasuje"
},
"pt-BR": {
newPasswordSameAsOld: "A nova senha deve ser diferente da antiga",
passwordConfirmNotMatch: "A confirmação da senha não corresponde"
},
ru: {
newPasswordSameAsOld: "Новый пароль должен отличаться от старого",
passwordConfirmNotMatch: "Подтверждение пароля не совпадает"
},
sk: {
newPasswordSameAsOld: "Nové heslo musí byť odlišné od starého",
passwordConfirmNotMatch: "Potvrdenie hesla sa nezhoduje"
},
sv: {
newPasswordSameAsOld: "Det nya lösenordet måste skilja sig från det gamla",
passwordConfirmNotMatch: "Lösenordsbekräftelsen matchar inte"
},
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,11 +1,7 @@
import * as child_process from "child_process";
import * as fs from "fs";
import { join } from "path";
import { waitForDebounceFactory } from "powerhooks/tools/waitForDebounce";
import chokidar from "chokidar";
import * as runExclusive from "run-exclusive";
import { Deferred } from "evt/tools/Deferred";
import chalk from "chalk";
import { startRebuildOnSrcChange } from "./startRebuildOnSrcChange";
fs.rmSync("node_modules", { recursive: true, force: true });
fs.rmSync("dist", { recursive: true, force: true });
@ -23,35 +19,7 @@ run("yarn install", { cwd: join("..", "keycloakify-starter") });
run(`npx ts-node --skipProject ${join("scripts", "link-in-app.ts")} keycloakify-starter`);
const { waitForDebounce } = waitForDebounceFactory({ delay: 400 });
const runYarnBuild = runExclusive.build(async () => {
console.log(chalk.green("Running `yarn build`"));
const dCompleted = new Deferred<void>();
const child = child_process.spawn("yarn", ["build"], {
env: process.env
});
child.stdout.on("data", data => process.stdout.write(data));
child.stderr.on("data", data => process.stderr.write(data));
child.on("exit", () => dCompleted.resolve());
await dCompleted.pr;
console.log("\n\n");
});
console.log(chalk.green("Watching for changes in src/"));
chokidar.watch("src", { ignoreInitial: true }).on("all", async () => {
await waitForDebounce();
runYarnBuild();
});
startRebuildOnSrcChange();
function run(command: string, options?: { cwd: string }) {
console.log(`$ ${command}`);

View File

@ -0,0 +1,31 @@
import * as child_process from "child_process";
import * as fs from "fs";
import { join } from "path";
import { startRebuildOnSrcChange } from "./startRebuildOnSrcChange";
run("yarn build");
run(`node ${join("dist", "bin", "main.js")} copy-keycloak-resources-to-public`, {
env: {
...process.env,
PUBLIC_DIR_PATH: join(".storybook", "static")
}
});
{
const child = child_process.spawn("npx", ["start-storybook", "-p", "6006"]);
child.stdout.on("data", data => process.stdout.write(data));
child.stderr.on("data", data => process.stderr.write(data));
child.on("exit", process.exit.bind(process));
}
startRebuildOnSrcChange();
function run(command: string, options?: { env?: NodeJS.ProcessEnv }) {
console.log(`$ ${command}`);
child_process.execSync(command, { stdio: "inherit", ...options });
}

View File

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

View File

@ -5,7 +5,7 @@ import {
import { assert } from "tsafe/assert";
/**
* This is an equivalent of process.env.PUBLIC_URL thay you can use in Webpack projects.
* This is an equivalent of process.env.PUBLIC_URL that you can use in Webpack projects.
* This works both in your main app and in your Keycloak theme.
*/
export const PUBLIC_URL = (() => {

View File

@ -1,9 +1,8 @@
import { lazy, Suspense } from "react";
import type { PageProps } from "keycloakify/account/pages/PageProps";
import type { I18n } from "keycloakify/account/i18n";
import type { KcContext } from "./kcContext";
import { assert, type Equals } from "tsafe/assert";
import FederatedIdentity from "./pages/FederatedIdentity";
import type { PageProps } from "keycloakify/account/pages/PageProps";
import type { KcContext } from "keycloakify/account/KcContext";
import { I18n } from "keycloakify/account/i18n";
const Password = lazy(() => import("keycloakify/account/pages/Password"));
const Account = lazy(() => import("keycloakify/account/pages/Account"));
@ -11,8 +10,9 @@ const Sessions = lazy(() => import("keycloakify/account/pages/Sessions"));
const Totp = lazy(() => import("keycloakify/account/pages/Totp"));
const Applications = lazy(() => import("keycloakify/account/pages/Applications"));
const Log = lazy(() => import("keycloakify/account/pages/Log"));
const FederatedIdentity = lazy(() => import("keycloakify/account/pages/FederatedIdentity"));
export default function Fallback(props: PageProps<KcContext, I18n>) {
export default function DefaultPage(props: PageProps<KcContext, I18n>) {
const { kcContext, ...rest } = props;
return (

View File

@ -1,6 +1,24 @@
import type { ThemeType, AccountThemePageId } from "keycloakify/bin/shared/constants";
import type { ValueOf } from "keycloakify/tools/ValueOf";
import { assert } from "tsafe/assert";
import type { Equals } from "tsafe";
import type { ThemeType, AccountThemePageId } from "keycloakify/bin/shared/constants";
export type ExtendKcContext<
KcContextExtension extends { properties?: Record<string, string | undefined> },
KcContextExtensionPerPage extends Record<string, Record<string, unknown>>
> = ValueOf<{
[PageId in keyof KcContextExtensionPerPage | KcContext["pageId"]]: Extract<
KcContext,
{ pageId: PageId }
> extends never
? KcContext.Common &
KcContextExtension & {
pageId: PageId;
} & KcContextExtensionPerPage[PageId]
: Extract<KcContext, { pageId: PageId }> &
KcContextExtension &
KcContextExtensionPerPage[PageId];
}>;
export type KcContext =
| KcContext.Password

View File

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

View File

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

View File

@ -1,4 +1,4 @@
import "minimal-polyfills/Object.fromEntries";
import "keycloakify/tools/Object.fromEntries";
import { resources_common, keycloak_resources } from "keycloakify/bin/shared/constants";
import { id } from "tsafe/id";
import type { KcContext } from "./KcContext";
@ -40,98 +40,33 @@ export const kcContextCommonMock: KcContext.Common = {
locale: {
supported: [
/* spell-checker: disable */
{
url: "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=de",
label: "Deutsch",
languageTag: "de"
},
{
url: "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=no",
label: "Norsk",
languageTag: "no"
},
{
url: "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=ru",
label: "Русский",
languageTag: "ru"
},
{
url: "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=sv",
label: "Svenska",
languageTag: "sv"
},
{
url: "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=pt-BR",
label: "Português (Brasil)",
languageTag: "pt-BR"
},
{
url: "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=lt",
label: "Lietuvių",
languageTag: "lt"
},
{
url: "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=en",
label: "English",
languageTag: "en"
},
{
url: "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=it",
label: "Italiano",
languageTag: "it"
},
{
url: "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=fr",
label: "Français",
languageTag: "fr"
},
{
url: "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=zh-CN",
label: "中文简体",
languageTag: "zh-CN"
},
{
url: "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=es",
label: "Español",
languageTag: "es"
},
{
url: "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=cs",
label: "Čeština",
languageTag: "cs"
},
{
url: "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=ja",
label: "日本語",
languageTag: "ja"
},
{
url: "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=sk",
label: "Slovenčina",
languageTag: "sk"
},
{
url: "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=pl",
label: "Polski",
languageTag: "pl"
},
{
url: "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=ca",
label: "Català",
languageTag: "ca"
},
{
url: "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=nl",
label: "Nederlands",
languageTag: "nl"
},
{
url: "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=tr",
label: "Türkçe",
languageTag: "tr"
}
["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
),
currentLanguageTag: "en"
},
features: {

View File

@ -1,19 +1,17 @@
import { useEffect } from "react";
import { clsx } from "keycloakify/tools/clsx";
import { type TemplateProps } from "keycloakify/account/TemplateProps";
import { useGetClassName } from "keycloakify/account/lib/useGetClassName";
import { createUseInsertLinkTags } from "keycloakify/tools/useInsertLinkTags";
import { useSetClassName } from "keycloakify/tools/useSetClassName";
import type { KcContext } from "./kcContext";
import type { I18n } from "./i18n";
import { assert } from "keycloakify/tools/assert";
const { useInsertLinkTags } = createUseInsertLinkTags();
import { clsx } from "keycloakify/tools/clsx";
import { getKcClsx } from "keycloakify/account/lib/kcClsx";
import { useInsertLinkTags } from "keycloakify/tools/useInsertLinkTags";
import { useSetClassName } from "keycloakify/tools/useSetClassName";
import type { TemplateProps } from "keycloakify/account/TemplateProps";
import type { I18n } from "./i18n";
import type { KcContext } from "./KcContext";
export default function Template(props: TemplateProps<KcContext, I18n>) {
const { kcContext, i18n, doUseDefaultCss, active, classes, children } = props;
const { getClassName } = useGetClassName({ doUseDefaultCss, classes });
const { kcClsx } = getKcClsx({ doUseDefaultCss, classes });
const { msg, msgStr, getChangeLocalUrl, labelBySupportedLanguageTag, currentLanguageTag } = i18n;
@ -25,12 +23,12 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
useSetClassName({
qualifiedName: "html",
className: getClassName("kcHtmlClass")
className: kcClsx("kcHtmlClass")
});
useSetClassName({
qualifiedName: "body",
className: clsx("admin-console", "user", getClassName("kcBodyClass"))
className: clsx("admin-console", "user", kcClsx("kcBodyClass"))
});
useEffect(() => {
@ -46,6 +44,7 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
}, []);
const { areAllStyleSheetsLoaded } = useInsertLinkTags({
componentOrHookName: "Template",
hrefs: !doUseDefaultCss
? []
: [
@ -74,7 +73,6 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
{realm.internationalizationEnabled && (assert(locale !== undefined), true) && locale.supported.length > 1 && (
<li>
<div className="kc-dropdown" id="kc-locale-dropdown">
{/* eslint-disable-next-line jsx-a11y/anchor-is-valid */}
<a href="#" id="kc-current-locale-link">
{labelBySupportedLanguageTag[currentLanguageTag]}
</a>

View File

@ -1,17 +1,13 @@
import type { ReactNode } from "react";
import type { KcContext } from "./kcContext";
import type { I18n } from "./i18n";
export type TemplateProps<
KcContext extends KcContext.Common,
I18nExtended extends I18n
> = {
export type TemplateProps<KcContext, I18n> = {
kcContext: KcContext;
i18n: I18nExtended;
i18n: I18n;
doUseDefaultCss: boolean;
active: string;
classes?: Partial<Record<ClassKey, string>>;
children: ReactNode;
active: string;
};
export type ClassKey =

View File

@ -1,11 +1,10 @@
import "minimal-polyfills/Object.fromEntries";
//NOTE for later: https://github.com/remarkjs/react-markdown/blob/236182ecf30bd89c1e5a7652acaf8d0bf81e6170/src/renderers.js#L7-L35
import { useEffect, useState, useRef } from "react";
import fallbackMessages from "./baseMessages/en";
import { getMessages } from "./baseMessages";
import "keycloakify/tools/Object.fromEntries";
import { useEffect, useState } from "react";
import { assert } from "tsafe/assert";
import type { KcContext } from "../kcContext/KcContext";
import { Markdown } from "keycloakify/tools/Markdown";
import messages_fallbackLanguage from "./baseMessages/en";
import { getMessages } from "./baseMessages";
import type { KcContext } from "../KcContext";
import { Reflect } from "tsafe/Reflect";
export const fallbackLanguageTag = "en";
@ -18,7 +17,7 @@ export type KcContextLike = {
assert<KcContext extends KcContextLike ? true : false>();
export type MessageKey = keyof typeof fallbackMessages | keyof (typeof keycloakifyExtraMessages)[typeof fallbackLanguageTag];
export type MessageKey = keyof typeof messages_fallbackLanguage;
export type GenericI18n<MessageKey extends string> = {
/**
@ -53,188 +52,272 @@ export type GenericI18n<MessageKey extends string> = {
*/
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"
* advancedMsg("${access-denied} foo bar") === <span>${msgStr("access-denied")} foo bar<span> === <span>Access denied foo bar</span>
* {
* en: {
* "access-denied": "Access denied",
* "foo": "Foo {0} {1}",
* "bar": "Bar {0}"
* }
* }
*
* advancedMsg("${access-denied} foo bar") === <span>{msgStr("access-denied")} foo bar<span> === <span>Access denied foo bar</span>
* advancedMsg("${access-denied}") === advancedMsg("access-denied") === msg("access-denied") === <span>Access denied</span>
* advancedMsg("${not-a-message-key}") === advancedMsg(not-a-message-key") === <span>not-a-message-key</span>
* advancedMsg("${bar}", "<strong>c</strong>")
* === <span>{msgStr("bar", "<strong>XXX</strong>")}<span>
* === <span>Bar &lt;strong&gt;XXX&lt;/strong&gt;</span> (The html in the arg is partially escaped for security reasons, it might be untrusted)
* advancedMsg("${foo} xx ${bar}", "a", "b", "c")
* === <span>{msgStr("foo", "a", "b")} xx {msgStr("bar")}<span>
* === <span>Foo a b xx Bar {0}</span> (The substitution are only applied in the first message)
*/
advancedMsg: (key: string, ...args: (string | undefined)[]) => JSX.Element;
/**
* Examples assuming currentLanguageTag === "en"
* advancedMsg("${access-denied} foo bar") === msg("access-denied") + " foo bar" === "Access denied foo bar"
* advancedMsg("${not-a-message-key}") === advancedMsg(not-a-message-key") === "not-a-message-key"
* See advancedMsg() but instead of returning a JSX.Element it returns a string.
*/
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 I18n = GenericI18n<MessageKey>;
function createGetI18n<ExtraMessageKey extends string = never>(extraMessages: { [languageTag: string]: { [key in ExtraMessageKey]: string } }) {
type I18n = GenericI18n<MessageKey | ExtraMessageKey>;
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" | "getChangeLocalUrl" | "labelBySupportedLanguageTag"> = {
currentLanguageTag: kcContext.locale?.currentLanguageTag ?? fallbackLanguageTag,
getChangeLocalUrl: 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, ExtraMessageKey>({
messages_fallbackLanguage,
extraMessages_fallbackLanguage: extraMessages[fallbackLanguageTag],
extraMessages: extraMessages[partialI18n.currentLanguageTag]
});
const isCurrentLanguageFallbackLanguage = partialI18n.currentLanguageTag === fallbackLanguageTag;
const result: Result = {
i18n: {
...partialI18n,
...createI18nTranslationFunctions({ messages: undefined }),
isFetchingTranslations: !isCurrentLanguageFallbackLanguage
},
prI18n_currentLanguage: isCurrentLanguageFallbackLanguage
? undefined
: (async () => {
const messages = await getMessages(partialI18n.currentLanguageTag);
const i18n_currentLanguage: I18n = {
...partialI18n,
...createI18nTranslationFunctions({ messages }),
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 };
}
export function createUseI18n<ExtraMessageKey extends string = never>(extraMessages: {
[languageTag: string]: { [key in ExtraMessageKey]: string };
}) {
function useI18n(params: { kcContext: KcContextLike }): GenericI18n<MessageKey | ExtraMessageKey> | null {
type I18n = GenericI18n<MessageKey | ExtraMessageKey>;
const { getI18n } = createGetI18n(extraMessages);
function useI18n(params: { kcContext: KcContextLike }): { i18n: I18n } {
const { kcContext } = params;
const [i18n, setI18n] = useState<GenericI18n<ExtraMessageKey | MessageKey> | undefined>(undefined);
const { i18n, prI18n_currentLanguage } = getI18n({ kcContext });
const refHasStartedFetching = useRef(false);
const [i18n_toReturn, setI18n_toReturn] = useState<I18n>(i18n);
useEffect(() => {
if (refHasStartedFetching.current) {
return;
}
let isActive = true;
refHasStartedFetching.current = true;
(async () => {
const { currentLanguageTag = fallbackLanguageTag } = kcContext.locale ?? {};
setI18n({
...createI18nTranslationFunctions({
fallbackMessages: {
...fallbackMessages,
...(keycloakifyExtraMessages[fallbackLanguageTag] ?? {}),
...(extraMessages[fallbackLanguageTag] ?? {})
} as any,
messages: {
...(await getMessages(currentLanguageTag)),
...((keycloakifyExtraMessages as any)[currentLanguageTag] ?? {}),
...(extraMessages[currentLanguageTag] ?? {})
} as any
}),
currentLanguageTag,
getChangeLocalUrl: 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])
)
});
})();
}, []);
return i18n ?? null;
}
return { useI18n };
}
function createI18nTranslationFunctions<MessageKey extends string>(params: {
fallbackMessages: Record<MessageKey, string>;
messages: Record<MessageKey, string>;
}): Pick<GenericI18n<MessageKey>, "msg" | "msgStr" | "advancedMsg" | "advancedMsgStr"> {
const { fallbackMessages, messages } = params;
function resolveMsg(props: { key: string; args: (string | undefined)[]; doRenderMarkdown: boolean }): string | JSX.Element | undefined {
const { key, args, doRenderMarkdown } = props;
const messageOrUndefined: string | undefined = (messages as any)[key] ?? (fallbackMessages as any)[key];
if (messageOrUndefined === undefined) {
return undefined;
}
const message = messageOrUndefined;
const messageWithArgsInjectedIfAny = (() => {
const startIndex = message
.match(/{[0-9]+}/g)
?.map(g => g.match(/{([0-9]+)}/)![1])
.map(indexStr => parseInt(indexStr))
.sort((a, b) => a - b)[0];
if (startIndex === undefined) {
// No {0} in message (no arguments expected)
return message;
}
let messageWithArgsInjected = message;
args.forEach((arg, i) => {
if (arg === undefined) {
prI18n_currentLanguage?.then(i18n => {
if (!isActive) {
return;
}
messageWithArgsInjected = messageWithArgsInjected.replace(new RegExp(`\\{${i + startIndex}\\}`, "g"), arg);
setI18n_toReturn(i18n);
});
return messageWithArgsInjected;
})();
return () => {
isActive = false;
};
}, []);
return doRenderMarkdown ? (
<Markdown allowDangerousHtml renderers={{ paragraph: "span" }}>
{messageWithArgsInjectedIfAny}
</Markdown>
) : (
messageWithArgsInjectedIfAny
);
return { i18n: i18n_toReturn };
}
function resolveMsgAdvanced(props: { key: string; args: (string | undefined)[]; doRenderMarkdown: boolean }): JSX.Element | string {
const { key, args, doRenderMarkdown } = props;
const match = key.match(/^\$\{([^{]+)\}$/);
const keyUnwrappedFromCurlyBraces = match === null ? key : match[1];
const out = resolveMsg({
key: keyUnwrappedFromCurlyBraces,
args,
doRenderMarkdown
});
return (out !== undefined ? out : doRenderMarkdown ? <span>{keyUnwrappedFromCurlyBraces}</span> : keyUnwrappedFromCurlyBraces) as any;
}
return {
msgStr: (key, ...args) => resolveMsg({ key, args, doRenderMarkdown: false }) as string,
msg: (key, ...args) => resolveMsg({ key, args, doRenderMarkdown: true }) as JSX.Element,
advancedMsg: (key, ...args) =>
resolveMsgAdvanced({
key,
args,
doRenderMarkdown: true
}) as JSX.Element,
advancedMsgStr: (key, ...args) =>
resolveMsgAdvanced({
key,
args,
doRenderMarkdown: false
}) as string
};
return { useI18n, ofTypeI18n: Reflect<I18n>() };
}
const keycloakifyExtraMessages = {
en: {
shouldBeEqual: "{0} should be equal to {1}",
shouldBeDifferent: "{0} should be different to {1}",
shouldMatchPattern: "Pattern should match: `/{0}/`",
mustBeAnInteger: "Must be an integer",
notAValidOption: "Not a valid option",
newPasswordSameAsOld: "New password must be different from the old one",
passwordConfirmNotMatch: "Password confirmation does not match"
},
fr: {
/* spell-checker: disable */
shouldBeEqual: "{0} doit être égal à {1}",
shouldBeDifferent: "{0} doit être différent de {1}",
shouldMatchPattern: "Dois respecter le schéma: `/{0}/`",
mustBeAnInteger: "Doit être un nombre entier",
notAValidOption: "N'est pas une option valide",
function createI18nTranslationFunctionsFactory<MessageKey extends string, ExtraMessageKey extends string>(params: {
messages_fallbackLanguage: Record<MessageKey, string>;
extraMessages_fallbackLanguage: Record<ExtraMessageKey, string> | undefined;
extraMessages: Partial<Record<ExtraMessageKey, string>> | undefined;
}) {
const { extraMessages } = params;
logoutConfirmTitle: "Déconnexion",
logoutConfirmHeader: "Êtes-vous sûr(e) de vouloir vous déconnecter ?",
doLogout: "Se déconnecter",
newPasswordSameAsOld: "Le nouveau mot de passe doit être différent de l'ancien",
passwordConfirmNotMatch: "La confirmation du mot de passe ne correspond pas"
/* spell-checker: enable */
const messages_fallbackLanguage = {
...params.messages_fallbackLanguage,
...params.extraMessages_fallbackLanguage
};
function createI18nTranslationFunctions(params: {
messages: Partial<Record<MessageKey, string>> | undefined;
}): Pick<GenericI18n<MessageKey | ExtraMessageKey>, "msg" | "msgStr" | "advancedMsg" | "advancedMsgStr"> {
const messages = {
...params.messages,
...extraMessages
};
function resolveMsg(props: { key: string; args: (string | undefined)[]; doRenderAsHtml: boolean }): string | JSX.Element | undefined {
const { key, args, doRenderAsHtml } = props;
const messageOrUndefined: string | undefined = (messages as any)[key] ?? (messages_fallbackLanguage as any)[key];
if (messageOrUndefined === undefined) {
return undefined;
}
const message = messageOrUndefined;
const messageWithArgsInjectedIfAny = (() => {
const startIndex = message
.match(/{[0-9]+}/g)
?.map(g => g.match(/{([0-9]+)}/)![1])
.map(indexStr => parseInt(indexStr))
.sort((a, b) => a - b)[0];
if (startIndex === undefined) {
// No {0} in message (no arguments expected)
return message;
}
let messageWithArgsInjected = message;
args.forEach((arg, i) => {
if (arg === undefined) {
return;
}
messageWithArgsInjected = messageWithArgsInjected.replace(
new RegExp(`\\{${i + startIndex}\\}`, "g"),
arg.replace(/</g, "&lt;").replace(/>/g, "&gt;")
);
});
return messageWithArgsInjected;
})();
return doRenderAsHtml ? (
<span
// NOTE: The message is trusted. The arguments are not but are escaped.
dangerouslySetInnerHTML={{
__html: messageWithArgsInjectedIfAny
}}
/>
) : (
messageWithArgsInjectedIfAny
);
}
function resolveMsgAdvanced(props: { key: string; args: (string | undefined)[]; doRenderAsHtml: boolean }): JSX.Element | string {
const { key, args, doRenderAsHtml } = props;
if (!/\$\{[^}]+\}/.test(key)) {
const resolvedMessage = resolveMsg({ key, args, doRenderAsHtml });
if (resolvedMessage === undefined) {
return doRenderAsHtml ? <span dangerouslySetInnerHTML={{ __html: key }} /> : key;
}
return resolvedMessage;
}
let isFirstMatch = true;
const resolvedComplexMessage = key.replace(/\$\{([^}]+)\}/g, (...[, key_i]) => {
const replaceBy = resolveMsg({ key: key_i, args: isFirstMatch ? args : [], doRenderAsHtml: false }) ?? key_i;
isFirstMatch = false;
return replaceBy;
});
return doRenderAsHtml ? <span dangerouslySetInnerHTML={{ __html: resolvedComplexMessage }} /> : resolvedComplexMessage;
}
return {
msgStr: (key, ...args) => resolveMsg({ key, args, doRenderAsHtml: false }) as string,
msg: (key, ...args) => resolveMsg({ key, args, doRenderAsHtml: true }) as JSX.Element,
advancedMsg: (key, ...args) =>
resolveMsgAdvanced({
key,
args,
doRenderAsHtml: true
}) as JSX.Element,
advancedMsgStr: (key, ...args) =>
resolveMsgAdvanced({
key,
args,
doRenderAsHtml: false
}) as string
};
}
};
return { createI18nTranslationFunctions };
}

View File

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

View File

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

View File

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

View File

@ -1,23 +0,0 @@
import type { DeepPartial } from "keycloakify/tools/DeepPartial";
import type { ExtendKcContext } from "./getKcContextFromWindow";
import { createGetKcContext } from "./createGetKcContext";
/** NOTE: We now recommend using createGetKcContext instead of this function to make storybook integration easier
* See: https://github.com/keycloakify/keycloakify-starter/blob/main/src/keycloak-theme/account/kcContext.ts
*/
export function getKcContext<
KcContextExtension extends { pageId: string } = never
>(params?: {
mockPageId?: ExtendKcContext<KcContextExtension>["pageId"];
mockData?: readonly DeepPartial<ExtendKcContext<KcContextExtension>>[];
}): { kcContext: ExtendKcContext<KcContextExtension> | undefined } {
const { mockPageId, mockData } = params ?? {};
const { getKcContext } = createGetKcContext({
mockData
});
const { kcContext } = getKcContext({ mockPageId });
return { kcContext };
}

View File

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

View File

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

View File

@ -1,7 +1,7 @@
import { createUseClassName } from "keycloakify/lib/useGetClassName";
import { createGetKcClsx } from "keycloakify/lib/getKcClsx";
import type { ClassKey } from "keycloakify/account/TemplateProps";
export const { useGetClassName } = createUseClassName<ClassKey>({
export const { getKcClsx } = createGetKcClsx<ClassKey>({
defaultClasses: {
kcHtmlClass: undefined,
kcBodyClass: undefined,
@ -19,3 +19,7 @@ export const { useGetClassName } = createUseClassName<ClassKey>({
"pf-c-form__helper-text pf-m-error required kc-feedback-text"
}
});
export type { ClassKey };
export type KcClsx = ReturnType<typeof getKcClsx>["kcClsx"];

View File

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

View File

@ -1,17 +1,12 @@
import { clsx } from "keycloakify/tools/clsx";
import { getKcClsx } from "keycloakify/account/lib/kcClsx";
import type { PageProps } from "keycloakify/account/pages/PageProps";
import { useGetClassName } from "keycloakify/account/lib/useGetClassName";
import type { KcContext } from "../kcContext";
import type { KcContext } from "../KcContext";
import type { I18n } from "../i18n";
function isArrayWithEmptyObject(variable: any): boolean {
return Array.isArray(variable) && variable.length === 1 && typeof variable[0] === "object" && Object.keys(variable[0]).length === 0;
}
export default function Applications(props: PageProps<Extract<KcContext, { pageId: "applications.ftl" }>, I18n>) {
const { kcContext, i18n, doUseDefaultCss, classes, Template } = props;
const { getClassName } = useGetClassName({
const { kcClsx } = getKcClsx({
doUseDefaultCss,
classes
});
@ -118,7 +113,7 @@ export default function Applications(props: PageProps<Extract<KcContext, { pageI
application.additionalGrants.length > 0 ? (
<button
type="submit"
className={clsx(getClassName("kcButtonPrimaryClass"), getClassName("kcButtonClass"))}
className={kcClsx("kcButtonPrimaryClass", "kcButtonClass")}
id={`revoke-${application.client.clientId}`}
name="clientId"
value={application.client.id}
@ -136,3 +131,7 @@ export default function Applications(props: PageProps<Extract<KcContext, { pageI
</Template>
);
}
function isArrayWithEmptyObject(variable: any): boolean {
return Array.isArray(variable) && variable.length === 1 && typeof variable[0] === "object" && Object.keys(variable[0]).length === 0;
}

View File

@ -1,6 +1,6 @@
import { PageProps } from "keycloakify/account";
import { I18n } from "keycloakify/account/i18n";
import { KcContext } from "keycloakify/account/kcContext";
import type { PageProps } from "keycloakify/account/pages/PageProps";
import type { KcContext } from "../KcContext";
import type { I18n } from "../i18n";
export default function FederatedIdentity(props: PageProps<Extract<KcContext, { pageId: "federatedIdentity.ftl" }>, I18n>) {
const { kcContext, i18n, doUseDefaultCss, classes, Template } = props;

View File

@ -1,13 +1,13 @@
import type { Key } from "react";
import { getKcClsx } from "keycloakify/account/lib/kcClsx";
import type { PageProps } from "keycloakify/account/pages/PageProps";
import type { KcContext } from "../kcContext";
import type { KcContext } from "../KcContext";
import type { I18n } from "../i18n";
import { Key } from "react";
import { useGetClassName } from "keycloakify/account/lib/useGetClassName";
export default function Log(props: PageProps<Extract<KcContext, { pageId: "log.ftl" }>, I18n>) {
const { kcContext, i18n, doUseDefaultCss, classes, Template } = props;
const { getClassName } = useGetClassName({
const { kcClsx } = getKcClsx({
doUseDefaultCss,
classes
});
@ -18,7 +18,7 @@ export default function Log(props: PageProps<Extract<KcContext, { pageId: "log.f
return (
<Template {...{ kcContext, i18n, doUseDefaultCss, classes }} active="log">
<div className={getClassName("kcContentWrapperClass")}>
<div className={kcClsx("kcContentWrapperClass")}>
<div className="col-md-10">
<h2>{msg("accountLogHtmlTitle")}</h2>
</div>

View File

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

View File

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

View File

@ -1,13 +1,12 @@
import { clsx } from "keycloakify/tools/clsx";
import { getKcClsx } from "keycloakify/account/lib/kcClsx";
import type { PageProps } from "keycloakify/account/pages/PageProps";
import { useGetClassName } from "keycloakify/account/lib/useGetClassName";
import type { KcContext } from "../kcContext";
import type { KcContext } from "../KcContext";
import type { I18n } from "../i18n";
export default function Sessions(props: PageProps<Extract<KcContext, { pageId: "sessions.ftl" }>, I18n>) {
const { kcContext, i18n, doUseDefaultCss, Template, classes } = props;
const { getClassName } = useGetClassName({
const { kcClsx } = getKcClsx({
doUseDefaultCss,
classes
});
@ -17,7 +16,7 @@ export default function Sessions(props: PageProps<Extract<KcContext, { pageId: "
const { msg } = i18n;
return (
<Template {...{ kcContext, i18n, doUseDefaultCss, classes }} active="sessions">
<div className={getClassName("kcContentWrapperClass")}>
<div className={kcClsx("kcContentWrapperClass")}>
<div className="col-md-10">
<h2>{msg("sessionsHtmlTitle")}</h2>
</div>
@ -56,7 +55,7 @@ export default function Sessions(props: PageProps<Extract<KcContext, { pageId: "
<form action={url.sessionsUrl} method="post">
<input type="hidden" id="stateChecker" name="stateChecker" value={stateChecker} />
<button id="logout-all-sessions" type="submit" className={clsx(getClassName("kcButtonDefaultClass"), getClassName("kcButtonClass"))}>
<button id="logout-all-sessions" type="submit" className={kcClsx("kcButtonDefaultClass", "kcButtonClass")}>
{msg("doLogOutAllSessions")}
</button>
</form>

View File

@ -1,21 +1,20 @@
import { clsx } from "keycloakify/tools/clsx";
import { getKcClsx } from "keycloakify/account/lib/kcClsx";
import type { PageProps } from "keycloakify/account/pages/PageProps";
import { useGetClassName } from "keycloakify/account/lib/useGetClassName";
import type { KcContext } from "../kcContext";
import type { KcContext } from "../KcContext";
import type { I18n } from "../i18n";
import { MessageKey } from "keycloakify/account/i18n/i18n";
export default function Totp(props: PageProps<Extract<KcContext, { pageId: "totp.ftl" }>, I18n>) {
const { kcContext, i18n, doUseDefaultCss, Template, classes } = props;
const { getClassName } = useGetClassName({
const { kcClsx } = getKcClsx({
doUseDefaultCss,
classes
});
const { totp, mode, url, messagesPerField, stateChecker } = kcContext;
const { msg, msgStr } = i18n;
const { msg, msgStr, advancedMsg } = i18n;
const algToKeyUriAlg: Record<(typeof kcContext)["totp"]["policy"]["algorithm"], string> = {
HmacSHA1: "SHA1",
@ -78,9 +77,7 @@ export default function Totp(props: PageProps<Extract<KcContext, { pageId: "totp
<li>
<p>{msg("totpStep1")}</p>
<ul id="kc-totp-supported-apps">
{totp.supportedApplications?.map(app => <li key={app}>{msg(app as MessageKey)}</li>)}
</ul>
<ul id="kc-totp-supported-apps">{totp.supportedApplications?.map(app => <li key={app}>{advancedMsg(app)}</li>)}</ul>
</li>
{mode && mode == "manual" ? (
@ -143,9 +140,9 @@ export default function Totp(props: PageProps<Extract<KcContext, { pageId: "totp
</li>
</ol>
<hr />
<form action={url.totpUrl} className={getClassName("kcFormClass")} id="kc-totp-settings-form" method="post">
<form action={url.totpUrl} className={kcClsx("kcFormClass")} id="kc-totp-settings-form" method="post">
<input type="hidden" id="stateChecker" name="stateChecker" value={stateChecker} />
<div className={getClassName("kcFormGroupClass")}>
<div className={kcClsx("kcFormGroupClass")}>
<div className="col-sm-2 col-md-2">
<label htmlFor="totp" className="control-label">
{msg("authenticatorCode")}
@ -158,12 +155,12 @@ export default function Totp(props: PageProps<Extract<KcContext, { pageId: "totp
id="totp"
name="totp"
autoComplete="off"
className={getClassName("kcInputClass")}
className={kcClsx("kcInputClass")}
aria-invalid={messagesPerField.existsError("totp")}
/>
{messagesPerField.existsError("totp") && (
<span id="input-error-otp-code" className={getClassName("kcInputErrorMessageClass")} aria-live="polite">
<span id="input-error-otp-code" className={kcClsx("kcInputErrorMessageClass")} aria-live="polite">
{messagesPerField.get("totp")}
</span>
)}
@ -172,9 +169,9 @@ export default function Totp(props: PageProps<Extract<KcContext, { pageId: "totp
{mode && <input type="hidden" id="mode" value={mode} />}
</div>
<div className={getClassName("kcFormGroupClass")}>
<div className={kcClsx("kcFormGroupClass")}>
<div className="col-sm-2 col-md-2">
<label htmlFor="userLabel" className={getClassName("kcLabelClass")}>
<label htmlFor="userLabel" className={kcClsx("kcLabelClass")}>
{msg("totpDeviceName")}
</label>
{totp.otpCredentials.length >= 1 && <span className="required">*</span>}
@ -185,37 +182,28 @@ export default function Totp(props: PageProps<Extract<KcContext, { pageId: "totp
id="userLabel"
name="userLabel"
autoComplete="off"
className={getClassName("kcInputClass")}
className={kcClsx("kcInputClass")}
aria-invalid={messagesPerField.existsError("userLabel")}
/>
{messagesPerField.existsError("userLabel") && (
<span id="input-error-otp-label" className={getClassName("kcInputErrorMessageClass")} aria-live="polite">
<span id="input-error-otp-label" className={kcClsx("kcInputErrorMessageClass")} aria-live="polite">
{messagesPerField.get("userLabel")}
</span>
)}
</div>
</div>
<div id="kc-form-buttons" className={clsx(getClassName("kcFormGroupClass"), "text-right")}>
<div className={getClassName("kcInputWrapperClass")}>
<div id="kc-form-buttons" className={clsx(kcClsx("kcFormGroupClass"), "text-right")}>
<div className={kcClsx("kcInputWrapperClass")}>
<input
type="submit"
className={clsx(
getClassName("kcButtonClass"),
getClassName("kcButtonPrimaryClass"),
getClassName("kcButtonLargeClass")
)}
className={kcClsx("kcButtonClass", "kcButtonPrimaryClass", "kcButtonLargeClass")}
id="saveTOTPBtn"
value={msgStr("doSave")}
/>
<button
type="submit"
className={clsx(
getClassName("kcButtonClass"),
getClassName("kcButtonDefaultClass"),
getClassName("kcButtonLargeClass"),
getClassName("kcButtonLargeClass")
)}
className={kcClsx("kcButtonClass", "kcButtonDefaultClass", "kcButtonLargeClass", "kcButtonLargeClass")}
id="cancelTOTPBtn"
name="submitAction"
value="Cancel"

109
src/bin/add-story.ts Normal file
View File

@ -0,0 +1,109 @@
import { getThisCodebaseRootDirPath } from "./tools/getThisCodebaseRootDirPath";
import cliSelect from "cli-select";
import {
loginThemePageIds,
accountThemePageIds,
type LoginThemePageId,
type AccountThemePageId,
themeTypes,
type ThemeType
} from "./shared/constants";
import { capitalize } from "tsafe/capitalize";
import * as fs from "fs";
import { join as pathJoin, relative as pathRelative, dirname as pathDirname } from "path";
import { kebabCaseToCamelCase } from "./tools/kebabCaseToSnakeCase";
import { assert, Equals } from "tsafe/assert";
import { getThemeSrcDirPath } from "./shared/getThemeSrcDirPath";
import type { CliCommandOptions } from "./main";
import { getBuildContext } from "./shared/buildContext";
import chalk from "chalk";
export async function command(params: { cliCommandOptions: CliCommandOptions }) {
const { cliCommandOptions } = params;
const buildContext = getBuildContext({
cliCommandOptions
});
console.log(chalk.cyan("Theme type:"));
const { value: themeType } = await cliSelect<ThemeType>({
values: [...themeTypes]
}).catch(() => {
process.exit(-1);
});
console.log(`${themeType}`);
console.log(chalk.cyan("Select the page you want to create a Storybook for:"));
const { value: pageId } = await cliSelect<LoginThemePageId | AccountThemePageId>({
values: (() => {
switch (themeType) {
case "login":
return [...loginThemePageIds];
case "account":
return [...accountThemePageIds];
}
assert<Equals<typeof themeType, never>>(false);
})()
}).catch(() => {
process.exit(-1);
});
console.log(`${pageId}`);
const { themeSrcDirPath } = getThemeSrcDirPath({
projectDirPath: buildContext.projectDirPath
});
const componentBasename = capitalize(kebabCaseToCamelCase(pageId)).replace(
/ftl$/,
"stories.tsx"
);
const targetFilePath = pathJoin(
themeSrcDirPath,
themeType,
"pages",
componentBasename
);
if (fs.existsSync(targetFilePath)) {
console.log(`${pathRelative(process.cwd(), targetFilePath)} already exists`);
process.exit(-1);
}
const componentCode = fs
.readFileSync(
pathJoin(
getThisCodebaseRootDirPath(),
"stories",
themeType,
"pages",
componentBasename
)
)
.toString("utf8")
.replace('import React from "react";\n', "");
{
const targetDirPath = pathDirname(targetFilePath);
if (!fs.existsSync(targetDirPath)) {
fs.mkdirSync(targetDirPath, { recursive: true });
}
}
fs.writeFileSync(targetFilePath, Buffer.from(componentCode, "utf8"));
console.log(
[
`${chalk.green("✓")} ${chalk.bold(
pathJoin(".", pathRelative(process.cwd(), targetFilePath))
)} copy pasted from the Keycloakify source code into your project`,
`You can start storybook with ${chalk.bold("yarn storybook")}`
].join("\n")
);
}

View File

@ -1,16 +1,13 @@
import { copyKeycloakResourcesToPublic } from "./shared/copyKeycloakResourcesToPublic";
import { readBuildOptions } from "./shared/buildOptions";
import { getBuildContext } from "./shared/buildContext";
import type { CliCommandOptions } from "./main";
export async function command(params: { cliCommandOptions: CliCommandOptions }) {
const { cliCommandOptions } = params;
const buildOptions = readBuildOptions({ cliCommandOptions });
const buildContext = getBuildContext({ cliCommandOptions });
await copyKeycloakResourcesToPublic({
buildOptions: {
...buildOptions,
publicDirPath: buildOptions.reactAppRootDirPath
}
buildContext
});
}

View File

@ -1,63 +0,0 @@
import { join as pathJoin, relative as pathRelative, sep as pathSep } from "path";
import { promptKeycloakVersion } from "./shared/promptKeycloakVersion";
import { readBuildOptions } from "./shared/buildOptions";
import { downloadKeycloakDefaultTheme } from "./shared/downloadKeycloakDefaultTheme";
import { transformCodebase } from "./tools/transformCodebase";
import type { CliCommandOptions } from "./main";
import chalk from "chalk";
export async function command(params: { cliCommandOptions: CliCommandOptions }) {
const { cliCommandOptions } = params;
const buildOptions = readBuildOptions({
cliCommandOptions
});
console.log(
chalk.cyan(
"Select the Keycloak version from which you want to download the builtins theme:"
)
);
const { keycloakVersion } = await promptKeycloakVersion({
startingFromMajor: undefined,
cacheDirPath: buildOptions.cacheDirPath
});
console.log(`${keycloakVersion}`);
const destDirPath = pathJoin(
buildOptions.keycloakifyBuildDirPath,
"src",
"main",
"resources",
"theme"
);
console.log(
[
`Downloading builtins theme of Keycloak ${keycloakVersion} here:`,
`- ${chalk.bold(
`.${pathSep}${pathJoin(pathRelative(process.cwd(), destDirPath), "base")}`
)}`,
`- ${chalk.bold(
`.${pathSep}${pathJoin(
pathRelative(process.cwd(), destDirPath),
"keycloak"
)}`
)}`
].join("\n")
);
const { defaultThemeDirPath } = await downloadKeycloakDefaultTheme({
keycloakVersion,
buildOptions
});
transformCodebase({
srcDirPath: defaultThemeDirPath,
destDirPath
});
console.log(chalk.green(`✓ done`));
}

View File

@ -17,13 +17,13 @@ import { kebabCaseToCamelCase } from "./tools/kebabCaseToSnakeCase";
import { assert, Equals } from "tsafe/assert";
import { getThemeSrcDirPath } from "./shared/getThemeSrcDirPath";
import type { CliCommandOptions } from "./main";
import { readBuildOptions } from "./shared/buildOptions";
import { getBuildContext } from "./shared/buildContext";
import chalk from "chalk";
export async function command(params: { cliCommandOptions: CliCommandOptions }) {
const { cliCommandOptions } = params;
const buildOptions = readBuildOptions({
const buildContext = getBuildContext({
cliCommandOptions
});
@ -39,13 +39,26 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
console.log(chalk.cyan("Select the page you want to customize:"));
const { value: pageId } = await cliSelect<LoginThemePageId | AccountThemePageId>({
const templateValue = "Template.tsx (Layout common to every page)";
const userProfileFormFieldsValue =
"UserProfileFormFields.tsx (Renders the form of the register.ftl, login-update-profile.ftl, update-email.ftl and idp-review-user-profile.ftl)";
const { value: pageIdOrComponent } = await cliSelect<
| LoginThemePageId
| AccountThemePageId
| typeof templateValue
| typeof userProfileFormFieldsValue
>({
values: (() => {
switch (themeType) {
case "login":
return [...loginThemePageIds];
return [
templateValue,
userProfileFormFieldsValue,
...loginThemePageIds
];
case "account":
return [...accountThemePageIds];
return [templateValue, ...accountThemePageIds];
}
assert<Equals<typeof themeType, never>>(false);
})()
@ -53,27 +66,45 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
process.exit(-1);
});
console.log(`${pageId}`);
const componentPageBasename = capitalize(kebabCaseToCamelCase(pageId)).replace(
/ftl$/,
"tsx"
);
console.log(`${pageIdOrComponent}`);
const { themeSrcDirPath } = getThemeSrcDirPath({
reactAppRootDirPath: buildOptions.reactAppRootDirPath
projectDirPath: buildContext.projectDirPath
});
const componentBasename = (() => {
if (pageIdOrComponent === templateValue) {
return "Template.tsx";
}
if (pageIdOrComponent === userProfileFormFieldsValue) {
return "UserProfileFormFields.tsx";
}
return capitalize(kebabCaseToCamelCase(pageIdOrComponent)).replace(/ftl$/, "tsx");
})();
const pagesOrDot = (() => {
if (
pageIdOrComponent === templateValue ||
pageIdOrComponent === userProfileFormFieldsValue
) {
return ".";
}
return "pages";
})();
const targetFilePath = pathJoin(
themeSrcDirPath,
themeType,
"pages",
componentPageBasename
pagesOrDot,
componentBasename
);
if (fs.existsSync(targetFilePath)) {
console.log(
`${pageId} is already ejected, ${pathRelative(
`${pageIdOrComponent} is already ejected, ${pathRelative(
process.cwd(),
targetFilePath
)} already exists`
@ -82,6 +113,18 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
process.exit(-1);
}
const componentCode = fs
.readFileSync(
pathJoin(
getThisCodebaseRootDirPath(),
"src",
themeType,
pagesOrDot,
componentBasename
)
)
.toString("utf8");
{
const targetDirPath = pathDirname(targetFilePath);
@ -90,28 +133,66 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
}
}
const componentPageContent = fs
.readFileSync(
pathJoin(
getThisCodebaseRootDirPath(),
"src",
themeType,
"pages",
componentPageBasename
)
)
.toString("utf8");
fs.writeFileSync(targetFilePath, Buffer.from(componentCode, "utf8"));
fs.writeFileSync(targetFilePath, Buffer.from(componentPageContent, "utf8"));
console.log(
`${chalk.green("✓")} ${chalk.bold(
pathJoin(".", pathRelative(process.cwd(), targetFilePath))
)} copy pasted from the Keycloakify source code into your project`
);
edit_KcApp: {
if (
pageIdOrComponent !== templateValue &&
pageIdOrComponent !== userProfileFormFieldsValue
) {
break edit_KcApp;
}
const kcAppTsxPath = pathJoin(themeSrcDirPath, themeType, "KcPage.tsx");
const kcAppTsxCode = fs.readFileSync(kcAppTsxPath).toString("utf8");
const modifiedKcAppTsxCode = (() => {
switch (pageIdOrComponent) {
case templateValue:
return kcAppTsxCode.replace(
`keycloakify/${themeType}/Template`,
"./Template"
);
case userProfileFormFieldsValue:
return kcAppTsxCode.replace(
`keycloakify/login/UserProfileFormFields`,
"./UserProfileFormFields"
);
}
assert<Equals<typeof pageIdOrComponent, never>>(false);
})();
if (kcAppTsxCode === modifiedKcAppTsxCode) {
console.log(
chalk.red(
"Unable to automatically update KcPage.tsx, please update it manually"
)
);
return;
}
fs.writeFileSync(kcAppTsxPath, Buffer.from(modifiedKcAppTsxCode, "utf8"));
console.log(
`${chalk.green("✓")} ${chalk.bold(
pathJoin(".", pathRelative(process.cwd(), kcAppTsxPath))
)} Updated`
);
return;
}
const userProfileFormFieldComponentName = "UserProfileFormFields";
console.log(
[
``,
`${chalk.green("✓")} ${chalk.bold(
pathJoin(".", pathRelative(process.cwd(), targetFilePath))
)} copy pasted from the Keycloakify source code into your project`,
``,
`You now need to update your page router:`,
``,
@ -120,21 +201,21 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
".",
pathRelative(process.cwd(), themeSrcDirPath),
themeType,
"KcApp.tsx"
"KcPage.tsx"
)
)}:`,
chalk.grey("```"),
`// ...`,
``,
chalk.green(
`+const ${componentPageBasename.replace(
`+const ${componentBasename.replace(
/.tsx$/,
""
)} = lazy(() => import("./pages/${componentPageBasename}"));`
)} = lazy(() => import("./pages/${componentBasename}"));`
),
...[
``,
` export default function KcApp(props: { kcContext: KcContext; }) {`,
` export default function KcPage(props: { kcContext: KcContext; }) {`,
``,
` // ...`,
``,
@ -143,16 +224,17 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
` {(() => {`,
` switch (kcContext.pageId) {`,
` // ...`,
`+ case "${pageId}": return (`,
`+ <Login`,
`+ case "${pageIdOrComponent}": return (`,
`+ <${componentBasename}`,
`+ {...{ kcContext, i18n, classes }}`,
`+ Template={Template}`,
...(!componentPageContent.includes(userProfileFormFieldComponentName)
`+ doUseDefaultCss={true}`,
...(!componentCode.includes(userProfileFormFieldComponentName)
? []
: [
`+ ${userProfileFormFieldComponentName}={${userProfileFormFieldComponentName}}`
`+ ${userProfileFormFieldComponentName}={${userProfileFormFieldComponentName}}`,
`+ doMakeUserConfirmPassword={doMakeUserConfirmPassword}`
]),
`+ doUseDefaultCss={true}`,
`+ />`,
`+ );`,
` default: return <Fallback /* .. */ />;`,

View File

@ -2,7 +2,7 @@ import { downloadKeycloakDefaultTheme } from "./shared/downloadKeycloakDefaultTh
import { join as pathJoin, relative as pathRelative } from "path";
import { transformCodebase } from "./tools/transformCodebase";
import { promptKeycloakVersion } from "./shared/promptKeycloakVersion";
import { readBuildOptions } from "./shared/buildOptions";
import { getBuildContext } from "./shared/buildContext";
import * as fs from "fs";
import { getThemeSrcDirPath } from "./shared/getThemeSrcDirPath";
import type { CliCommandOptions } from "./main";
@ -10,10 +10,10 @@ import type { CliCommandOptions } from "./main";
export async function command(params: { cliCommandOptions: CliCommandOptions }) {
const { cliCommandOptions } = params;
const buildOptions = readBuildOptions({ cliCommandOptions });
const buildContext = getBuildContext({ cliCommandOptions });
const { themeSrcDirPath } = getThemeSrcDirPath({
reactAppRootDirPath: buildOptions.reactAppRootDirPath
projectDirPath: buildContext.projectDirPath
});
const emailThemeSrcDirPath = pathJoin(themeSrcDirPath, "email");
@ -34,12 +34,13 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
const { keycloakVersion } = await promptKeycloakVersion({
// NOTE: This is arbitrary
startingFromMajor: 17,
cacheDirPath: buildOptions.cacheDirPath
excludeMajorVersions: [],
cacheDirPath: buildContext.cacheDirPath
});
const { defaultThemeDirPath } = await downloadKeycloakDefaultTheme({
keycloakVersion,
buildOptions
buildContext
});
transformCodebase({

View File

@ -5,12 +5,12 @@ import type {
} from "./extensionVersions";
import { join as pathJoin, dirname as pathDirname } from "path";
import { transformCodebase } from "../../tools/transformCodebase";
import type { BuildOptions } from "../../shared/buildOptions";
import type { BuildContext } from "../../shared/buildContext";
import * as fs from "fs/promises";
import { accountV1ThemeName } from "../../shared/constants";
import {
generatePom,
BuildOptionsLike as BuildOptionsLike_generatePom
BuildContextLike as BuildContextLike_generatePom
} from "./generatePom";
import { readFileSync } from "fs";
import { isInside } from "../../tools/isInside";
@ -18,7 +18,7 @@ import child_process from "child_process";
import { rmSync } from "../../tools/fs.rmSync";
import { getMetaInfKeycloakThemesJsonFilePath } from "../../shared/metaInfKeycloakThemes";
export type BuildOptionsLike = BuildOptionsLike_generatePom & {
export type BuildContextLike = BuildContextLike_generatePom & {
keycloakifyBuildDirPath: string;
themeNames: string[];
artifactId: string;
@ -26,57 +26,34 @@ export type BuildOptionsLike = BuildOptionsLike_generatePom & {
cacheDirPath: string;
};
assert<BuildOptions extends BuildOptionsLike ? true : false>();
assert<BuildContext extends BuildContextLike ? true : false>();
export async function buildJar(params: {
jarFileBasename: string;
keycloakAccountV1Version: KeycloakAccountV1Version;
keycloakThemeAdditionalInfoExtensionVersion: KeycloakThemeAdditionalInfoExtensionVersion;
buildOptions: BuildOptionsLike;
resourcesDirPath: string;
buildContext: BuildContextLike;
}): Promise<void> {
const {
jarFileBasename,
keycloakAccountV1Version,
keycloakThemeAdditionalInfoExtensionVersion,
buildOptions
resourcesDirPath,
buildContext
} = params;
const keycloakifyBuildTmpDirPath = pathJoin(
buildOptions.cacheDirPath,
buildContext.cacheDirPath,
jarFileBasename.replace(".jar", "")
);
rmSync(keycloakifyBuildTmpDirPath, { recursive: true, force: true });
{
const transformCodebase_common = (params: {
fileRelativePath: string;
sourceCode: Buffer;
}): { modifiedSourceCode: Buffer } | undefined => {
const { fileRelativePath, sourceCode } = params;
if (
fileRelativePath ===
getMetaInfKeycloakThemesJsonFilePath({ keycloakifyBuildDirPath: "." })
) {
return { modifiedSourceCode: sourceCode };
}
for (const themeName of [...buildOptions.themeNames, accountV1ThemeName]) {
if (
isInside({
dirPath: pathJoin("src", "main", "resources", "theme", themeName),
filePath: fileRelativePath
})
) {
return { modifiedSourceCode: sourceCode };
}
}
return undefined;
};
const transformCodebase_patchForUsingBuiltinAccountV1 =
transformCodebase({
srcDirPath: resourcesDirPath,
destDirPath: pathJoin(keycloakifyBuildTmpDirPath, "src", "main", "resources"),
transformSourceCode:
keycloakAccountV1Version !== null
? undefined
: (params: {
@ -87,13 +64,7 @@ export async function buildJar(params: {
if (
isInside({
dirPath: pathJoin(
"src",
"main",
"resources",
"theme",
accountV1ThemeName
),
dirPath: pathJoin("theme", accountV1ThemeName),
filePath: fileRelativePath
})
) {
@ -103,7 +74,7 @@ export async function buildJar(params: {
if (
fileRelativePath ===
getMetaInfKeycloakThemesJsonFilePath({
keycloakifyBuildDirPath: "."
resourcesDirPath: "."
})
) {
const keycloakThemesJsonParsed = JSON.parse(
@ -125,18 +96,10 @@ export async function buildJar(params: {
};
}
for (const themeName of buildOptions.themeNames) {
for (const themeName of buildContext.themeNames) {
if (
fileRelativePath ===
pathJoin(
"src",
"main",
"resources",
"theme",
themeName,
"account",
"theme.properties"
)
pathJoin("theme", themeName, "account", "theme.properties")
) {
const modifiedSourceCode = Buffer.from(
sourceCode
@ -157,31 +120,8 @@ export async function buildJar(params: {
}
return { modifiedSourceCode: sourceCode };
};
transformCodebase({
srcDirPath: buildOptions.keycloakifyBuildDirPath,
destDirPath: keycloakifyBuildTmpDirPath,
transformSourceCode: params => {
const resultCommon = transformCodebase_common(params);
if (transformCodebase_patchForUsingBuiltinAccountV1 === undefined) {
return resultCommon;
}
if (resultCommon === undefined) {
return undefined;
}
const { modifiedSourceCode } = resultCommon;
return transformCodebase_patchForUsingBuiltinAccountV1({
...params,
sourceCode: modifiedSourceCode
});
}
});
}
}
});
route_legacy_pages: {
// NOTE: If there's no account theme there is no special target for keycloak 24 and up so we create
@ -203,7 +143,7 @@ export async function buildJar(params: {
}
(["register.ftl", "login-update-profile.ftl"] as const).forEach(pageId =>
buildOptions.themeNames.map(themeName => {
buildContext.themeNames.map(themeName => {
const ftlFilePath = pathJoin(
keycloakifyBuildTmpDirPath,
"src",
@ -244,7 +184,7 @@ export async function buildJar(params: {
{
const { pomFileCode } = generatePom({
buildOptions,
buildContext,
keycloakAccountV1Version,
keycloakThemeAdditionalInfoExtensionVersion
});
@ -285,9 +225,9 @@ export async function buildJar(params: {
pathJoin(
keycloakifyBuildTmpDirPath,
"target",
`${buildOptions.artifactId}-${buildOptions.themeVersion}.jar`
`${buildContext.artifactId}-${buildContext.themeVersion}.jar`
),
pathJoin(buildOptions.keycloakifyBuildDirPath, jarFileBasename)
pathJoin(buildContext.keycloakifyBuildDirPath, jarFileBasename)
);
rmSync(keycloakifyBuildTmpDirPath, { recursive: true });

View File

@ -5,25 +5,27 @@ import {
keycloakThemeAdditionalInfoExtensionVersions
} from "./extensionVersions";
import { getKeycloakVersionRangeForJar } from "./getKeycloakVersionRangeForJar";
import { buildJar, BuildOptionsLike as BuildOptionsLike_buildJar } from "./buildJar";
import type { BuildOptions } from "../../shared/buildOptions";
import { buildJar, BuildContextLike as BuildContextLike_buildJar } from "./buildJar";
import type { BuildContext } from "../../shared/buildContext";
import { getJarFileBasename } from "../../shared/getJarFileBasename";
import { readMetaInfKeycloakThemes } from "../../shared/metaInfKeycloakThemes";
import { readMetaInfKeycloakThemes_fromResourcesDirPath } from "../../shared/metaInfKeycloakThemes";
import { accountV1ThemeName } from "../../shared/constants";
export type BuildOptionsLike = BuildOptionsLike_buildJar & {
export type BuildContextLike = BuildContextLike_buildJar & {
keycloakifyBuildDirPath: string;
};
assert<BuildOptions extends BuildOptionsLike ? true : false>();
assert<BuildContext extends BuildContextLike ? true : false>();
export async function buildJars(params: {
buildOptions: BuildOptionsLike;
resourcesDirPath: string;
onlyBuildJarFileBasename: string | undefined;
buildContext: BuildContextLike;
}): Promise<void> {
const { buildOptions } = params;
const { onlyBuildJarFileBasename, resourcesDirPath, buildContext } = params;
const doesImplementAccountTheme = readMetaInfKeycloakThemes({
keycloakifyBuildDirPath: buildOptions.keycloakifyBuildDirPath
const doesImplementAccountTheme = readMetaInfKeycloakThemes_fromResourcesDirPath({
resourcesDirPath
}).themes.some(({ name }) => name === accountV1ThemeName);
await Promise.all(
@ -56,12 +58,20 @@ export async function buildJars(params: {
keycloakVersionRange
});
if (
onlyBuildJarFileBasename !== undefined &&
onlyBuildJarFileBasename !== jarFileBasename
) {
return undefined;
}
return {
keycloakThemeAdditionalInfoExtensionVersion,
jarFileBasename
};
}
)
.filter(exclude(undefined))
.map(
({
keycloakThemeAdditionalInfoExtensionVersion,
@ -71,7 +81,8 @@ export async function buildJars(params: {
jarFileBasename,
keycloakAccountV1Version,
keycloakThemeAdditionalInfoExtensionVersion,
buildOptions
resourcesDirPath,
buildContext
})
)
)

View File

@ -1,5 +1,5 @@
// NOTE: v0.5 is a dummy version.
export const keycloakAccountV1Versions = [null, "0.3", "0.4"] as const;
export const keycloakAccountV1Versions = [null, "0.3", "0.4", "0.6"] as const;
/**
* https://central.sonatype.com/artifact/io.phasetwo.keycloak/keycloak-account-v1

View File

@ -1,27 +1,27 @@
import { assert } from "tsafe/assert";
import type { BuildOptions } from "../../shared/buildOptions";
import type { BuildContext } from "../../shared/buildContext";
import type {
KeycloakAccountV1Version,
KeycloakThemeAdditionalInfoExtensionVersion
} from "./extensionVersions";
export type BuildOptionsLike = {
export type BuildContextLike = {
groupId: string;
artifactId: string;
themeVersion: string;
};
assert<BuildOptions extends BuildOptionsLike ? true : false>();
assert<BuildContext extends BuildContextLike ? true : false>();
export function generatePom(params: {
keycloakAccountV1Version: KeycloakAccountV1Version;
keycloakThemeAdditionalInfoExtensionVersion: KeycloakThemeAdditionalInfoExtensionVersion;
buildOptions: BuildOptionsLike;
buildContext: BuildContextLike;
}) {
const {
keycloakAccountV1Version,
keycloakThemeAdditionalInfoExtensionVersion,
buildOptions
buildContext
} = params;
const { pomFileCode } = (function generatePomFileCode(): {
@ -33,10 +33,10 @@ export function generatePom(params: {
` xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"`,
` xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">`,
` <modelVersion>4.0.0</modelVersion>`,
` <groupId>${buildOptions.groupId}</groupId>`,
` <artifactId>${buildOptions.artifactId}</artifactId>`,
` <version>${buildOptions.themeVersion}</version>`,
` <name>${buildOptions.artifactId}</name>`,
` <groupId>${buildContext.groupId}</groupId>`,
` <artifactId>${buildContext.artifactId}</artifactId>`,
` <version>${buildContext.themeVersion}</version>`,
` <name>${buildContext.artifactId}</name>`,
` <description />`,
` <packaging>jar</packaging>`,
` <properties>`,

View File

@ -44,12 +44,20 @@ export function getKeycloakVersionRangeForJar(params: {
case null:
return undefined;
case "1.1.5":
return "24-and-above" as const;
return "24" as const;
}
assert<
Equals<typeof keycloakThemeAdditionalInfoExtensionVersion, never>
>(false);
case "0.6":
switch (keycloakThemeAdditionalInfoExtensionVersion) {
case null:
return undefined;
case "1.1.5":
return "25-and-above" as const;
}
}
assert<Equals<typeof keycloakAccountV1Version, never>>(false);
})();
assert<
@ -65,7 +73,6 @@ export function getKeycloakVersionRangeForJar(params: {
if (keycloakAccountV1Version !== null) {
return undefined;
}
switch (keycloakThemeAdditionalInfoExtensionVersion) {
case null:
return "21-and-below";

View File

@ -180,10 +180,42 @@ try {
<#if attribute.annotations.inputTypePlaceholder??>
"${attribute.annotations.inputTypePlaceholder}": decodeHtmlEntities("${advancedMsg(attribute.annotations.inputTypePlaceholder)?js_string}"),
</#if>
<!-- Loop through the options that are in attribute.validators.options.options -->
<#if (
attribute.annotations.inputOptionLabelsI18nPrefix?? &&
attribute.validators?? &&
attribute.validators.options??
)>
<#list attribute.validators.options.options as option>
"${attribute.annotations.inputOptionLabelsI18nPrefix}.${option}": decodeHtmlEntities("${msg(attribute.annotations.inputOptionLabelsI18nPrefix + "." + option)?js_string}"),
</#list>
</#if>
</#list>
};
</#if>
attributes_to_attributesByName: {
if( !out["profile"] ){
break attributes_to_attributesByName;
}
if( !out["profile"]["attributes"] ){
break attributes_to_attributesByName;
}
var attributes = out["profile"]["attributes"];
delete out["profile"]["attributes"];
out["profile"]["attributesByName"] = {};
attributes.forEach(function(attribute){
out["profile"]["attributesByName"][attribute.name] = attribute;
});
}
return out;
function decodeHtmlEntities(htmlStr){
@ -193,7 +225,7 @@ function decodeHtmlEntities(htmlStr){
decodeHtmlEntities.element = element;
}
element.innerHTML = htmlStr;
return textarea.value;
return element.value;
}
})();
@ -266,35 +298,45 @@ function decodeHtmlEntities(htmlStr){
!["name", "displayName", "displayNameHtml", "internationalizationEnabled", "registrationEmailAsUsername" ]?seq_contains(key)
) || (
"applications.ftl" == pageId &&
is_subpath(path, ["applications", "applications"]) &&
(
key == "realm" ||
key == "container"
)
) &&
is_subpath(path, ["applications", "applications"])
) || (
are_same_path(path, ["user"]) &&
key == "delegateForUpdate"
key == "delegateForUpdate" &&
are_same_path(path, ["user"])
) || (
<#-- Security audit forwarded by Garth (Gmail) -->
are_same_path(path, ["client", "attributes"]) &&
key == "saml.signing.private.key"
key == "saml.signing.private.key" &&
are_same_path(path, ["client", "attributes"])
) || (
<#-- See: https://github.com/keycloakify/keycloakify/issues/534 -->
are_same_path(path, ["login"]) &&
key == "password"
key == "password" &&
are_same_path(path, ["login"])
) || (
<#-- Remove realmAttributes added by https://github.com/jcputney/keycloak-theme-additional-info-extension for peace of mind. -->
are_same_path(path, []) &&
key == "realmAttributes"
key == "realmAttributes" &&
are_same_path(path, [])
) || (
<#-- attributesByName adds a lot of noise to the output and is not needed, we already have profile.attributes -->
key == "attributesByName" &&
are_same_path(path, ["profile"])
) || (
<#-- We already have the attributes in profile speedup the rendering by filtering it out from the register object -->
(key == "attributes" || key == "attributesByName") &&
are_same_path(path, ["register"])
)
>
<#local out_seq += ["/*" + path?join(".") + "." + key + " excluded*/"]>
<#continue>
</#if>
USER_DEFINED_EXCLUSIONS_eKsaY4ZsZ4eMr2
<#-- https://github.com/keycloakify/keycloakify/discussions/406 -->
<#if (
["register.ftl", "register-user-profile.ftl", "info.ftl", "login.ftl", "login-update-password.ftl", "login-oauth2-device-verify-user-code.ftl"]?seq_contains(pageId) &&
["register.ftl", "register-user-profile.ftl", "terms.ftl", "info.ftl", "login.ftl", "login-update-password.ftl", "login-oauth2-device-verify-user-code.ftl"]?seq_contains(pageId) &&
key == "attemptedUsername" && are_same_path(path, ["auth"])
)>
<#attempt>

View File

@ -4,7 +4,7 @@ import { generateCssCodeToDefineGlobals } from "../replacers/replaceImportsInCss
import { replaceImportsInInlineCssCode } from "../replacers/replaceImportsInInlineCssCode";
import * as fs from "fs";
import { join as pathJoin } from "path";
import type { BuildOptions } from "../../shared/buildOptions";
import type { BuildContext } from "../../shared/buildContext";
import { assert } from "tsafe/assert";
import {
type ThemeType,
@ -15,21 +15,22 @@ import {
} from "../../shared/constants";
import { getThisCodebaseRootDirPath } from "../../tools/getThisCodebaseRootDirPath";
export type BuildOptionsLike = {
export type BuildContextLike = {
bundler: "vite" | "webpack";
themeVersion: string;
urlPathname: string | undefined;
reactAppBuildDirPath: string;
projectBuildDirPath: string;
assetsDirPath: string;
kcContextExclusionsFtlCode: string | undefined;
};
assert<BuildOptions extends BuildOptionsLike ? true : false>();
assert<BuildContext extends BuildContextLike ? true : false>();
export function generateFtlFilesCodeFactory(params: {
themeName: string;
indexHtmlCode: string;
cssGlobalsToDefine: Record<string, string>;
buildOptions: BuildOptionsLike;
buildContext: BuildContextLike;
keycloakifyVersion: string;
themeType: ThemeType;
fieldNames: string[];
@ -38,7 +39,7 @@ export function generateFtlFilesCodeFactory(params: {
themeName,
cssGlobalsToDefine,
indexHtmlCode,
buildOptions,
buildContext,
keycloakifyVersion,
themeType,
fieldNames
@ -54,7 +55,7 @@ export function generateFtlFilesCodeFactory(params: {
const { fixedJsCode } = replaceImportsInJsCode({
jsCode,
buildOptions
buildContext
});
$(element).text(fixedJsCode);
@ -67,7 +68,7 @@ export function generateFtlFilesCodeFactory(params: {
const { fixedCssCode } = replaceImportsInInlineCssCode({
cssCode,
buildOptions
buildContext
});
$(element).text(fixedCssCode);
@ -90,7 +91,7 @@ export function generateFtlFilesCodeFactory(params: {
attrName,
href.replace(
new RegExp(
`^${(buildOptions.urlPathname ?? "/").replace(/\//g, "\\/")}`
`^${(buildContext.urlPathname ?? "/").replace(/\//g, "\\/")}`
),
`\${url.resourcesPath}/${basenameOfTheKeycloakifyResourcesDir}/`
)
@ -105,7 +106,7 @@ export function generateFtlFilesCodeFactory(params: {
"<style>",
generateCssCodeToDefineGlobals({
cssGlobalsToDefine,
buildOptions
buildContext
}).cssCodeToPrependInHead,
"</style>",
""
@ -133,13 +134,17 @@ export function generateFtlFilesCodeFactory(params: {
fieldNames.map(name => `"${name}"`).join(", ")
)
.replace("KEYCLOAKIFY_VERSION_xEdKd3xEdr", keycloakifyVersion)
.replace("KEYCLOAKIFY_THEME_VERSION_sIgKd3xEdr3dx", buildOptions.themeVersion)
.replace("KEYCLOAKIFY_THEME_VERSION_sIgKd3xEdr3dx", buildContext.themeVersion)
.replace("KEYCLOAKIFY_THEME_TYPE_dExKd3xEdr", themeType)
.replace("KEYCLOAKIFY_THEME_NAME_cXxKd3xEer", themeName)
.replace("RESOURCES_COMMON_cLsLsMrtDkpVv", resources_common)
.replace(
"lOCALIZATION_REALM_OVERRIDES_USER_PROFILE_PROPERTY_KEY_aaGLsPgGIdeeX",
nameOfTheLocalizationRealmOverridesUserProfileProperty
)
.replace(
"USER_DEFINED_EXCLUSIONS_eKsaY4ZsZ4eMr2",
buildContext.kcContextExclusionsFtlCode ?? ""
);
const ftlObjectToJsCodeDeclaringAnObjectPlaceholder =
'{ "x": "vIdLqMeOed9sdLdIdOxdK0d" }';

View File

@ -1,7 +1,7 @@
import * as fs from "fs";
import { join as pathJoin } from "path";
import { assert } from "tsafe/assert";
import type { BuildOptions } from "../../shared/buildOptions";
import type { BuildContext } from "../../shared/buildContext";
import {
resources_common,
lastKeycloakVersionWithAccountV1,
@ -10,27 +10,26 @@ import {
import { downloadKeycloakDefaultTheme } from "../../shared/downloadKeycloakDefaultTheme";
import { transformCodebase } from "../../tools/transformCodebase";
type BuildOptionsLike = {
export type BuildContextLike = {
cacheDirPath: string;
npmWorkspaceRootDirPath: string;
keycloakifyBuildDirPath: string;
};
assert<BuildOptions extends BuildOptionsLike ? true : false>();
assert<BuildContext extends BuildContextLike ? true : false>();
export async function bringInAccountV1(params: { buildOptions: BuildOptionsLike }) {
const { buildOptions } = params;
export async function bringInAccountV1(params: {
resourcesDirPath: string;
buildContext: BuildContextLike;
}) {
const { resourcesDirPath, buildContext } = params;
const { defaultThemeDirPath } = await downloadKeycloakDefaultTheme({
keycloakVersion: lastKeycloakVersionWithAccountV1,
buildOptions
buildContext
});
const accountV1DirPath = pathJoin(
buildOptions.keycloakifyBuildDirPath,
"src",
"main",
"resources",
resourcesDirPath,
"theme",
accountV1ThemeName,
"account"

View File

@ -8,6 +8,7 @@ import * as recast from "recast";
import * as babelParser from "@babel/parser";
import babelGenerate from "@babel/generator";
import * as babelTypes from "@babel/types";
import { escapeStringForPropertiesFile } from "../../tools/escapeStringForPropertiesFile";
export function generateMessageProperties(params: {
themeSrcDirPath: string;
@ -146,7 +147,7 @@ export function generateMessageProperties(params: {
for (const [languageTag, keyValueMap] of Object.entries(keyValueMapByLanguageTag)) {
const propertiesFileSource = Object.entries(keyValueMap)
.map(([key, value]) => `${key}=${escapeString(value)}`)
.map(([key, value]) => `${key}=${escapeStringForPropertiesFile(value)}`)
.join("\n");
out.push({
@ -164,68 +165,3 @@ export function generateMessageProperties(params: {
return out;
}
// Convert a JavaScript string to UTF-16 encoding
function toUTF16(codePoint: number): string {
if (codePoint <= 0xffff) {
// BMP character
return "\\u" + codePoint.toString(16).padStart(4, "0");
} else {
// Non-BMP character
codePoint -= 0x10000;
let highSurrogate = (codePoint >> 10) + 0xd800;
let lowSurrogate = (codePoint % 0x400) + 0xdc00;
return (
"\\u" +
highSurrogate.toString(16).padStart(4, "0") +
"\\u" +
lowSurrogate.toString(16).padStart(4, "0")
);
}
}
// Escapes special characters for use in a .properties file
function escapeString(str: string): string {
let escapedStr = "";
for (const char of [...str]) {
const codePoint = char.codePointAt(0);
if (!codePoint) continue;
switch (char) {
case "\n":
escapedStr += "\\n";
break;
case "\r":
escapedStr += "\\r";
break;
case "\t":
escapedStr += "\\t";
break;
case "\\":
escapedStr += "\\\\";
break;
case ":":
escapedStr += "\\:";
break;
case "=":
escapedStr += "\\=";
break;
case "#":
escapedStr += "\\#";
break;
case "!":
escapedStr += "\\!";
break;
case "'":
escapedStr += "''";
break;
default:
if (codePoint > 0x7f) {
escapedStr += toUTF16(codePoint); // Non-ASCII characters
} else {
escapedStr += char; // ASCII character needs no escape
}
}
}
return escapedStr;
}

View File

@ -1,34 +1,42 @@
import type { BuildOptions } from "../../shared/buildOptions";
import type { BuildContext } from "../../shared/buildContext";
import { assert } from "tsafe/assert";
import {
generateSrcMainResourcesForMainTheme,
type BuildOptionsLike as BuildOptionsLike_generateSrcMainResourcesForMainTheme
type BuildContextLike as BuildContextLike_generateSrcMainResourcesForMainTheme
} from "./generateSrcMainResourcesForMainTheme";
import { generateSrcMainResourcesForThemeVariant } from "./generateSrcMainResourcesForThemeVariant";
import fs from "fs";
import { rmSync } from "../../tools/fs.rmSync";
export type BuildOptionsLike = BuildOptionsLike_generateSrcMainResourcesForMainTheme & {
export type BuildContextLike = BuildContextLike_generateSrcMainResourcesForMainTheme & {
themeNames: string[];
};
assert<BuildOptions extends BuildOptionsLike ? true : false>();
assert<BuildContext extends BuildContextLike ? true : false>();
export async function generateSrcMainResources(params: {
buildOptions: BuildOptionsLike;
buildContext: BuildContextLike;
resourcesDirPath: string;
}): Promise<void> {
const { buildOptions } = params;
const { resourcesDirPath, buildContext } = params;
const [themeName, ...themeVariantNames] = buildOptions.themeNames;
const [themeName, ...themeVariantNames] = buildContext.themeNames;
if (fs.existsSync(resourcesDirPath)) {
rmSync(resourcesDirPath, { recursive: true });
}
await generateSrcMainResourcesForMainTheme({
resourcesDirPath,
themeName,
buildOptions
buildContext
});
for (const themeVariantName of themeVariantNames) {
generateSrcMainResourcesForThemeVariant({
resourcesDirPath,
themeName,
themeVariantName,
buildOptions
themeVariantName
});
}
}

View File

@ -3,7 +3,10 @@ import * as fs from "fs";
import { join as pathJoin, resolve as pathResolve } from "path";
import { replaceImportsInJsCode } from "../replacers/replaceImportsInJsCode";
import { replaceImportsInCssCode } from "../replacers/replaceImportsInCssCode";
import { generateFtlFilesCodeFactory } from "../generateFtl";
import {
generateFtlFilesCodeFactory,
type BuildContextLike as BuildContextLike_kcContextExclusionsFtlCode
} from "../generateFtl";
import {
type ThemeType,
lastKeycloakVersionWithAccountV1,
@ -14,13 +17,19 @@ import {
accountThemePageIds
} from "../../shared/constants";
import { isInside } from "../../tools/isInside";
import type { BuildOptions } from "../../shared/buildOptions";
import type { BuildContext } from "../../shared/buildContext";
import { assert, type Equals } from "tsafe/assert";
import { downloadKeycloakStaticResources } from "../../shared/downloadKeycloakStaticResources";
import {
downloadKeycloakStaticResources,
type BuildContextLike as BuildContextLike_downloadKeycloakStaticResources
} from "../../shared/downloadKeycloakStaticResources";
import { readFieldNameUsage } from "./readFieldNameUsage";
import { readExtraPagesNames } from "./readExtraPageNames";
import { generateMessageProperties } from "./generateMessageProperties";
import { bringInAccountV1 } from "./bringInAccountV1";
import {
bringInAccountV1,
type BuildContextLike as BuildContextLike_bringInAccountV1
} from "./bringInAccountV1";
import { getThemeSrcDirPath } from "../../shared/getThemeSrcDirPath";
import { rmSync } from "../../tools/fs.rmSync";
import { readThisNpmPackageVersion } from "../../tools/readThisNpmPackageVersion";
@ -29,44 +38,34 @@ import {
type MetaInfKeycloakTheme
} from "../../shared/metaInfKeycloakThemes";
import { objectEntries } from "tsafe/objectEntries";
import { escapeStringForPropertiesFile } from "../../tools/escapeStringForPropertiesFile";
export type BuildOptionsLike = {
bundler: "vite" | "webpack";
extraThemeProperties: string[] | undefined;
themeVersion: string;
loginThemeResourcesFromKeycloakVersion: string;
reactAppBuildDirPath: string;
cacheDirPath: string;
assetsDirPath: string;
urlPathname: string | undefined;
npmWorkspaceRootDirPath: string;
reactAppRootDirPath: string;
keycloakifyBuildDirPath: string;
};
export type BuildContextLike = BuildContextLike_kcContextExclusionsFtlCode &
BuildContextLike_downloadKeycloakStaticResources &
BuildContextLike_bringInAccountV1 & {
extraThemeProperties: string[] | undefined;
loginThemeResourcesFromKeycloakVersion: string;
projectDirPath: string;
projectBuildDirPath: string;
environmentVariables: { name: string; default: string }[];
};
assert<BuildOptions extends BuildOptionsLike ? true : false>();
assert<BuildContext extends BuildContextLike ? true : false>();
export async function generateSrcMainResourcesForMainTheme(params: {
themeName: string;
buildOptions: BuildOptionsLike;
resourcesDirPath: string;
buildContext: BuildContextLike;
}): Promise<void> {
const { themeName, buildOptions } = params;
const { themeName, resourcesDirPath, buildContext } = params;
const { themeSrcDirPath } = getThemeSrcDirPath({
reactAppRootDirPath: buildOptions.reactAppRootDirPath
projectDirPath: buildContext.projectDirPath
});
const getThemeTypeDirPath = (params: { themeType: ThemeType | "email" }) => {
const { themeType } = params;
return pathJoin(
buildOptions.keycloakifyBuildDirPath,
"src",
"main",
"resources",
"theme",
themeName,
themeType
);
return pathJoin(resourcesDirPath, "theme", themeName, themeType);
};
const cssGlobalsToDefine: Record<string, string> = {};
@ -114,7 +113,7 @@ export async function generateSrcMainResourcesForMainTheme(params: {
}
transformCodebase({
srcDirPath: buildOptions.reactAppBuildDirPath,
srcDirPath: buildContext.projectBuildDirPath,
destDirPath,
transformSourceCode: ({ filePath, sourceCode }) => {
//NOTE: Prevent cycles, excludes the folder we generated for debug in public/
@ -122,7 +121,7 @@ export async function generateSrcMainResourcesForMainTheme(params: {
if (
isInside({
dirPath: pathJoin(
buildOptions.reactAppBuildDirPath,
buildContext.projectBuildDirPath,
keycloak_resources
),
filePath
@ -153,7 +152,7 @@ export async function generateSrcMainResourcesForMainTheme(params: {
if (/\.js?$/i.test(filePath)) {
const { fixedJsCode } = replaceImportsInJsCode({
jsCode: sourceCode.toString("utf8"),
buildOptions
buildContext
});
return {
@ -169,10 +168,10 @@ export async function generateSrcMainResourcesForMainTheme(params: {
const { generateFtlFilesCode } = generateFtlFilesCodeFactory({
themeName,
indexHtmlCode: fs
.readFileSync(pathJoin(buildOptions.reactAppBuildDirPath, "index.html"))
.readFileSync(pathJoin(buildContext.projectBuildDirPath, "index.html"))
.toString("utf8"),
cssGlobalsToDefine,
buildOptions,
buildContext,
keycloakifyVersion: readThisNpmPackageVersion(),
themeType,
fieldNames: readFieldNameUsage({
@ -197,8 +196,6 @@ export async function generateSrcMainResourcesForMainTheme(params: {
].forEach(pageId => {
const { ftlCode } = generateFtlFilesCode({ pageId });
fs.mkdirSync(themeTypeDirPath, { recursive: true });
fs.writeFileSync(
pathJoin(themeTypeDirPath, pageId),
Buffer.from(ftlCode, "utf8")
@ -232,12 +229,12 @@ export async function generateSrcMainResourcesForMainTheme(params: {
case "account":
return lastKeycloakVersionWithAccountV1;
case "login":
return buildOptions.loginThemeResourcesFromKeycloakVersion;
return buildContext.loginThemeResourcesFromKeycloakVersion;
}
})(),
themeDirPath: pathResolve(pathJoin(themeTypeDirPath, "..")),
themeType,
buildOptions
buildContext
});
fs.writeFileSync(
@ -253,7 +250,11 @@ export async function generateSrcMainResourcesForMainTheme(params: {
}
assert<Equals<typeof themeType, never>>(false);
})()}`,
...(buildOptions.extraThemeProperties ?? [])
...(buildContext.extraThemeProperties ?? []),
buildContext.environmentVariables.map(
({ name, default: defaultValue }) =>
`${name}=\${env.${name}:${escapeStringForPropertiesFile(defaultValue)}}`
)
].join("\n\n"),
"utf8"
)
@ -277,7 +278,8 @@ export async function generateSrcMainResourcesForMainTheme(params: {
if (implementedThemeTypes.account) {
await bringInAccountV1({
buildOptions
resourcesDirPath,
buildContext
});
}
@ -299,7 +301,7 @@ export async function generateSrcMainResourcesForMainTheme(params: {
}
writeMetaInfKeycloakThemes({
keycloakifyBuildDirPath: buildOptions.keycloakifyBuildDirPath,
resourcesDirPath,
metaInfKeycloakThemes
});
}

View File

@ -1,33 +1,26 @@
import { join as pathJoin, extname as pathExtname, sep as pathSep } from "path";
import { transformCodebase } from "../../tools/transformCodebase";
import type { BuildOptions } from "../../shared/buildOptions";
import type { BuildContext } from "../../shared/buildContext";
import {
readMetaInfKeycloakThemes,
readMetaInfKeycloakThemes_fromResourcesDirPath,
writeMetaInfKeycloakThemes
} from "../../shared/metaInfKeycloakThemes";
import { assert } from "tsafe/assert";
export type BuildOptionsLike = {
export type BuildContextLike = {
keycloakifyBuildDirPath: string;
};
assert<BuildOptions extends BuildOptionsLike ? true : false>();
assert<BuildContext extends BuildContextLike ? true : false>();
export function generateSrcMainResourcesForThemeVariant(params: {
resourcesDirPath: string;
themeName: string;
themeVariantName: string;
buildOptions: BuildOptionsLike;
}) {
const { themeName, themeVariantName, buildOptions } = params;
const { resourcesDirPath, themeName, themeVariantName } = params;
const mainThemeDirPath = pathJoin(
buildOptions.keycloakifyBuildDirPath,
"src",
"main",
"resources",
"theme",
themeName
);
const mainThemeDirPath = pathJoin(resourcesDirPath, "theme", themeName);
transformCodebase({
srcDirPath: mainThemeDirPath,
@ -57,9 +50,10 @@ export function generateSrcMainResourcesForThemeVariant(params: {
});
{
const updatedMetaInfKeycloakThemes = readMetaInfKeycloakThemes({
keycloakifyBuildDirPath: buildOptions.keycloakifyBuildDirPath
});
const updatedMetaInfKeycloakThemes =
readMetaInfKeycloakThemes_fromResourcesDirPath({
resourcesDirPath
});
updatedMetaInfKeycloakThemes.themes.push({
name: themeVariantName,
@ -73,7 +67,7 @@ export function generateSrcMainResourcesForThemeVariant(params: {
});
writeMetaInfKeycloakThemes({
keycloakifyBuildDirPath: buildOptions.keycloakifyBuildDirPath,
resourcesDirPath,
metaInfKeycloakThemes: updatedMetaInfKeycloakThemes
});
}

View File

@ -21,7 +21,7 @@ export function readExtraPagesNames(params: {
}).filter(filePath => /\.(ts|tsx|js|jsx)$/.test(filePath));
const candidateFilePaths = filePaths.filter(filePath =>
/kcContext\.[^.]+$/.test(filePath)
/[kK]cContext\.[^.]+$/.test(filePath)
);
if (candidateFilePaths.length === 0) {

View File

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

View File

@ -2,13 +2,17 @@ import { generateSrcMainResources } from "./generateSrcMainResources";
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 { readBuildOptions } from "../shared/buildOptions";
import { vitePluginSubScriptEnvNames, skipBuildJarsEnvName } from "../shared/constants";
import { getBuildContext } from "../shared/buildContext";
import {
vitePluginSubScriptEnvNames,
onlyBuildJarFileBasenameEnvName
} 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 }) {
exit_if_maven_not_installed: {
@ -47,7 +51,7 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
const { cliCommandOptions } = params;
const buildOptions = readBuildOptions({ cliCommandOptions });
const buildContext = getBuildContext({ cliCommandOptions });
console.log(
[
@ -55,7 +59,7 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
chalk.green(
`Building the keycloak theme in .${pathSep}${pathRelative(
process.cwd(),
buildOptions.keycloakifyBuildDirPath
buildContext.keycloakifyBuildDirPath
)} ...`
)
].join(" ")
@ -64,44 +68,51 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
const startTime = Date.now();
{
if (!fs.existsSync(buildOptions.keycloakifyBuildDirPath)) {
fs.mkdirSync(buildOptions.keycloakifyBuildDirPath, {
if (!fs.existsSync(buildContext.keycloakifyBuildDirPath)) {
fs.mkdirSync(buildContext.keycloakifyBuildDirPath, {
recursive: true
});
}
fs.writeFileSync(
pathJoin(buildOptions.keycloakifyBuildDirPath, ".gitignore"),
pathJoin(buildContext.keycloakifyBuildDirPath, ".gitignore"),
Buffer.from("*", "utf8")
);
}
await generateSrcMainResources({ buildOptions });
const resourcesDirPath = pathJoin(buildContext.keycloakifyBuildDirPath, "resources");
await generateSrcMainResources({
resourcesDirPath,
buildContext
});
run_post_build_script: {
if (buildOptions.bundler !== "vite") {
if (buildContext.bundler !== "vite") {
break run_post_build_script;
}
child_process.execSync("npx vite", {
cwd: buildOptions.reactAppRootDirPath,
cwd: buildContext.projectDirPath,
env: {
...process.env,
[vitePluginSubScriptEnvNames.runPostBuildScript]:
JSON.stringify(buildOptions)
JSON.stringify(buildContext)
}
});
}
build_jars: {
if (process.env[skipBuildJarsEnvName]) {
break build_jars;
}
await buildJars({
resourcesDirPath,
buildContext,
onlyBuildJarFileBasename: process.env[onlyBuildJarFileBasenameEnvName]
});
await buildJars({ buildOptions });
}
rmSync(resourcesDirPath, { recursive: true });
console.log(
chalk.green(`✓ built in ${((Date.now() - startTime) / 1000).toFixed(2)}s`)
chalk.green(
`✓ keycloak theme built in ${((Date.now() - startTime) / 1000).toFixed(2)}s`
)
);
}

View File

@ -1,13 +1,13 @@
import * as crypto from "crypto";
import type { BuildOptions } from "../../shared/buildOptions";
import type { BuildContext } from "../../shared/buildContext";
import { assert } from "tsafe/assert";
import { basenameOfTheKeycloakifyResourcesDir } from "../../shared/constants";
export type BuildOptionsLike = {
export type BuildContextLike = {
urlPathname: string | undefined;
};
assert<BuildOptions extends BuildOptionsLike ? true : false>();
assert<BuildContext extends BuildContextLike ? true : false>();
export function replaceImportsInCssCode(params: { cssCode: string }): {
fixedCssCode: string;
@ -44,11 +44,11 @@ export function replaceImportsInCssCode(params: { cssCode: string }): {
export function generateCssCodeToDefineGlobals(params: {
cssGlobalsToDefine: Record<string, string>;
buildOptions: BuildOptionsLike;
buildContext: BuildContextLike;
}): {
cssCodeToPrependInHead: string;
} {
const { cssGlobalsToDefine, buildOptions } = params;
const { cssGlobalsToDefine, buildContext } = params;
return {
cssCodeToPrependInHead: [
@ -59,7 +59,7 @@ export function generateCssCodeToDefineGlobals(params: {
`--${cssVariableName}:`,
cssGlobalsToDefine[cssVariableName].replace(
new RegExp(
`url\\(${(buildOptions.urlPathname ?? "/").replace(
`url\\(${(buildContext.urlPathname ?? "/").replace(
/\//g,
"\\/"
)}`,

View File

@ -1,25 +1,25 @@
import type { BuildOptions } from "../../shared/buildOptions";
import type { BuildContext } from "../../shared/buildContext";
import { assert } from "tsafe/assert";
import { basenameOfTheKeycloakifyResourcesDir } from "../../shared/constants";
export type BuildOptionsLike = {
export type BuildContextLike = {
urlPathname: string | undefined;
};
assert<BuildOptions extends BuildOptionsLike ? true : false>();
assert<BuildContext extends BuildContextLike ? true : false>();
export function replaceImportsInInlineCssCode(params: {
cssCode: string;
buildOptions: BuildOptionsLike;
buildContext: BuildContextLike;
}): {
fixedCssCode: string;
} {
const { cssCode, buildOptions } = params;
const { cssCode, buildContext } = params;
const fixedCssCode = cssCode.replace(
buildOptions.urlPathname === undefined
buildContext.urlPathname === undefined
? /url\(["']?\/([^/][^)"']+)["']?\)/g
: new RegExp(`url\\(["']?${buildOptions.urlPathname}([^)"']+)["']?\\)`, "g"),
: new RegExp(`url\\(["']?${buildContext.urlPathname}([^)"']+)["']?\\)`, "g"),
(...[, group]) =>
`url(\${url.resourcesPath}/${basenameOfTheKeycloakifyResourcesDir}/${group})`
);

View File

@ -1,38 +1,38 @@
import { assert } from "tsafe/assert";
import type { BuildOptions } from "../../../shared/buildOptions";
import type { BuildContext } from "../../../shared/buildContext";
import { replaceImportsInJsCode_vite } from "./vite";
import { replaceImportsInJsCode_webpack } from "./webpack";
import * as fs from "fs";
export type BuildOptionsLike = {
reactAppBuildDirPath: string;
export type BuildContextLike = {
projectBuildDirPath: string;
assetsDirPath: string;
urlPathname: string | undefined;
bundler: "vite" | "webpack";
};
assert<BuildOptions extends BuildOptionsLike ? true : false>();
assert<BuildContext extends BuildContextLike ? true : false>();
export function replaceImportsInJsCode(params: {
jsCode: string;
buildOptions: BuildOptionsLike;
buildContext: BuildContextLike;
}) {
const { jsCode, buildOptions } = params;
const { jsCode, buildContext } = params;
const { fixedJsCode } = (() => {
switch (buildOptions.bundler) {
switch (buildContext.bundler) {
case "vite":
return replaceImportsInJsCode_vite({
jsCode,
buildOptions,
buildContext,
basenameOfAssetsFiles: readAssetsDirSync({
assetsDirPath: params.buildOptions.assetsDirPath
assetsDirPath: params.buildContext.assetsDirPath
})
});
case "webpack":
return replaceImportsInJsCode_webpack({
jsCode,
buildOptions
buildContext
});
}
})();

View File

@ -3,21 +3,21 @@ import {
basenameOfTheKeycloakifyResourcesDir
} from "../../../shared/constants";
import { assert } from "tsafe/assert";
import type { BuildOptions } from "../../../shared/buildOptions";
import type { BuildContext } from "../../../shared/buildContext";
import * as nodePath from "path";
import { replaceAll } from "../../../tools/String.prototype.replaceAll";
export type BuildOptionsLike = {
reactAppBuildDirPath: string;
export type BuildContextLike = {
projectBuildDirPath: string;
assetsDirPath: string;
urlPathname: string | undefined;
};
assert<BuildOptions extends BuildOptionsLike ? true : false>();
assert<BuildContext extends BuildContextLike ? true : false>();
export function replaceImportsInJsCode_vite(params: {
jsCode: string;
buildOptions: BuildOptionsLike;
buildContext: BuildContextLike;
basenameOfAssetsFiles: string[];
systemType?: "posix" | "win32";
}): {
@ -25,7 +25,7 @@ export function replaceImportsInJsCode_vite(params: {
} {
const {
jsCode,
buildOptions,
buildContext,
basenameOfAssetsFiles,
systemType = nodePath.sep === "/" ? "posix" : "win32"
} = params;
@ -35,11 +35,11 @@ export function replaceImportsInJsCode_vite(params: {
let fixedJsCode = jsCode;
replace_base_javacript_import: {
if (buildOptions.urlPathname === undefined) {
if (buildContext.urlPathname === undefined) {
break replace_base_javacript_import;
}
// Optimization
if (!jsCode.includes(buildOptions.urlPathname)) {
if (!jsCode.includes(buildContext.urlPathname)) {
break replace_base_javacript_import;
}
@ -47,7 +47,7 @@ export function replaceImportsInJsCode_vite(params: {
fixedJsCode = fixedJsCode.replace(
new RegExp(
`([\\w\\$][\\w\\d\\$]*)=function\\(([\\w\\$][\\w\\d\\$]*)\\)\\{return"${replaceAll(
buildOptions.urlPathname,
buildContext.urlPathname,
"/",
"\\/"
)}"\\+\\2\\}`,
@ -62,15 +62,15 @@ export function replaceImportsInJsCode_vite(params: {
// Example: "assets/ or "foo/bar/"
const staticDir = (() => {
let out = pathRelative(
buildOptions.reactAppBuildDirPath,
buildOptions.assetsDirPath
buildContext.projectBuildDirPath,
buildContext.assetsDirPath
);
out = replaceAll(out, pathSep, "/") + "/";
if (out === "/") {
throw new Error(
`The assetsDirPath must be a subdirectory of reactAppBuildDirPath`
`The assetsDirPath must be a subdirectory of projectBuildDirPath`
);
}
@ -93,7 +93,7 @@ export function replaceImportsInJsCode_vite(params: {
fixedJsCode = replaceAll(
fixedJsCode,
`"${buildOptions.urlPathname ?? "/"}${relativePathOfAssetFile}"`,
`"${buildContext.urlPathname ?? "/"}${relativePathOfAssetFile}"`,
`(window.${nameOfTheGlobal}.url.resourcesPath + "/${basenameOfTheKeycloakifyResourcesDir}/${relativePathOfAssetFile}")`
);
});

View File

@ -3,28 +3,28 @@ import {
basenameOfTheKeycloakifyResourcesDir
} from "../../../shared/constants";
import { assert } from "tsafe/assert";
import type { BuildOptions } from "../../../shared/buildOptions";
import type { BuildContext } from "../../../shared/buildContext";
import * as nodePath from "path";
import { replaceAll } from "../../../tools/String.prototype.replaceAll";
export type BuildOptionsLike = {
reactAppBuildDirPath: string;
export type BuildContextLike = {
projectBuildDirPath: string;
assetsDirPath: string;
urlPathname: string | undefined;
};
assert<BuildOptions extends BuildOptionsLike ? true : false>();
assert<BuildContext extends BuildContextLike ? true : false>();
export function replaceImportsInJsCode_webpack(params: {
jsCode: string;
buildOptions: BuildOptionsLike;
buildContext: BuildContextLike;
systemType?: "posix" | "win32";
}): {
fixedJsCode: string;
} {
const {
jsCode,
buildOptions,
buildContext,
systemType = nodePath.sep === "/" ? "posix" : "win32"
} = params;
@ -32,12 +32,12 @@ export function replaceImportsInJsCode_webpack(params: {
let fixedJsCode = jsCode;
if (buildOptions.urlPathname !== undefined) {
if (buildContext.urlPathname !== undefined) {
// "__esModule",{value:!0})},n.p="/foo-bar/",function(){if("undefined" -> ... n.p="/" ...
fixedJsCode = fixedJsCode.replace(
new RegExp(
`,([a-zA-Z]\\.[a-zA-Z])="${replaceAll(
buildOptions.urlPathname,
buildContext.urlPathname,
"/",
"\\/"
)}",`,
@ -50,15 +50,15 @@ export function replaceImportsInJsCode_webpack(params: {
// Example: "static/ or "foo/bar/"
const staticDir = (() => {
let out = pathRelative(
buildOptions.reactAppBuildDirPath,
buildOptions.assetsDirPath
buildContext.projectBuildDirPath,
buildContext.assetsDirPath
);
out = replaceAll(out, pathSep, "/") + "/";
if (out === "/") {
throw new Error(
`The assetsDirPath must be a subdirectory of reactAppBuildDirPath`
`The assetsDirPath must be a subdirectory of projectBuildDirPath`
);
}

View File

@ -5,7 +5,7 @@ import { readThisNpmPackageVersion } from "./tools/readThisNpmPackageVersion";
import * as child_process from "child_process";
export type CliCommandOptions = {
reactAppRootDirPath: string | undefined;
projectDirPath: string | undefined;
};
const program = termost<CliCommandOptions>(
@ -25,7 +25,7 @@ const program = termost<CliCommandOptions>(
const optionsKeys: string[] = [];
program.option({
key: "reactAppRootDirPath",
key: "projectDirPath",
name: (() => {
const long = "project";
const short = "p";
@ -134,20 +134,6 @@ program
}
});
program
.command({
name: "download-keycloak-default-theme",
description: "Download the built-in Keycloak theme."
})
.task({
skip,
handler: async cliCommandOptions => {
const { command } = await import("./download-keycloak-default-theme");
await command({ cliCommandOptions });
}
});
program
.command({
name: "eject-page",
@ -162,6 +148,20 @@ program
}
});
program
.command({
name: "add-story",
description: "Add *.stories.tsx file for a specific page to in your Storybook."
})
.task({
skip,
handler: async cliCommandOptions => {
const { command } = await import("./add-story");
await command({ cliCommandOptions });
}
});
program
.command({
name: "initialize-email-theme",
@ -191,6 +191,21 @@ program
}
});
program
.command({
name: "update-kc-gen",
description:
"(Webpack/Create-React-App only) Create/update the kc.gen.ts file in your project."
})
.task({
skip,
handler: async cliCommandOptions => {
const { command } = await import("./update-kc-gen");
await command({ cliCommandOptions });
}
});
// Fallback to build command if no command is provided
{
const [, , ...rest] = process.argv;

View File

@ -5,5 +5,5 @@ export type KeycloakVersionRange =
export namespace KeycloakVersionRange {
export type WithoutAccountTheme = "21-and-below" | "22-and-above";
export type WithAccountTheme = "21-and-below" | "23" | "24-and-above";
export type WithAccountTheme = "21-and-below" | "23" | "24" | "25-and-above";
}

View File

@ -9,18 +9,16 @@ import { assert } from "tsafe";
import * as child_process from "child_process";
import { vitePluginSubScriptEnvNames } from "./constants";
/** Consolidated build option gathered form CLI arguments and config in package.json */
export type BuildOptions = {
export type BuildContext = {
bundler: "vite" | "webpack";
themeVersion: string;
themeNames: string[];
themeNames: [string, ...string[]];
extraThemeProperties: string[] | undefined;
groupId: string;
artifactId: string;
loginThemeResourcesFromKeycloakVersion: string;
reactAppRootDirPath: string;
// TODO: Remove from vite type
reactAppBuildDirPath: string;
projectDirPath: string;
projectBuildDirPath: string;
/** Directory that keycloakify outputs to. Defaults to {cwd}/build_keycloak */
keycloakifyBuildDirPath: string;
publicDirPath: string;
@ -30,15 +28,19 @@ export type BuildOptions = {
urlPathname: string | undefined;
assetsDirPath: string;
npmWorkspaceRootDirPath: string;
kcContextExclusionsFtlCode: string | undefined;
environmentVariables: { name: string; default: string }[];
};
export type UserProvidedBuildOptions = {
export type BuildOptions = {
themeName?: string | string[];
environmentVariables?: { name: string; default: string }[];
extraThemeProperties?: string[];
artifactId?: string;
groupId?: string;
loginThemeResourcesFromKeycloakVersion?: string;
keycloakifyBuildDirPath?: string;
themeName?: string | string[];
kcContextExclusionsFtl?: string;
};
export type ResolvedViteConfig = {
@ -46,21 +48,21 @@ export type ResolvedViteConfig = {
publicDir: string;
assetsDir: string;
urlPathname: string | undefined;
userProvidedBuildOptions: UserProvidedBuildOptions;
buildOptions: BuildOptions;
};
export function readBuildOptions(params: {
export function getBuildContext(params: {
cliCommandOptions: CliCommandOptions;
}): BuildOptions {
}): BuildContext {
const { cliCommandOptions } = params;
const reactAppRootDirPath = (() => {
if (cliCommandOptions.reactAppRootDirPath === undefined) {
const projectDirPath = (() => {
if (cliCommandOptions.projectDirPath === undefined) {
return process.cwd();
}
return getAbsoluteAndInOsFormatPath({
pathIsh: cliCommandOptions.reactAppRootDirPath,
pathIsh: cliCommandOptions.projectDirPath,
cwd: process.cwd()
});
})();
@ -68,7 +70,7 @@ export function readBuildOptions(params: {
const { resolvedViteConfig } = (() => {
if (
fs
.readdirSync(reactAppRootDirPath)
.readdirSync(projectDirPath)
.find(fileBasename => fileBasename.startsWith("vite.config")) ===
undefined
) {
@ -77,7 +79,7 @@ export function readBuildOptions(params: {
const output = child_process
.execSync("npx vite", {
cwd: reactAppRootDirPath,
cwd: projectDirPath,
env: {
...process.env,
[vitePluginSubScriptEnvNames.resolveViteConfig]: "true"
@ -104,8 +106,8 @@ export function readBuildOptions(params: {
name: string;
version?: string;
homepage?: string;
keycloakify?: UserProvidedBuildOptions & {
reactAppBuildDirPath?: string;
keycloakify?: BuildOptions & {
projectBuildDirPath?: string;
};
};
@ -119,7 +121,7 @@ export function readBuildOptions(params: {
artifactId: z.string().optional(),
groupId: z.string().optional(),
loginThemeResourcesFromKeycloakVersion: z.string().optional(),
reactAppBuildDirPath: z.string().optional(),
projectBuildDirPath: z.string().optional(),
keycloakifyBuildDirPath: z.string().optional(),
themeName: z.union([z.string(), z.array(z.string())]).optional()
})
@ -135,20 +137,18 @@ export function readBuildOptions(params: {
return zParsedPackageJson.parse(
JSON.parse(
fs
.readFileSync(pathJoin(reactAppRootDirPath, "package.json"))
.toString("utf8")
fs.readFileSync(pathJoin(projectDirPath, "package.json")).toString("utf8")
)
);
})();
const userProvidedBuildOptions: UserProvidedBuildOptions = {
const buildOptions: BuildOptions = {
...parsedPackageJson.keycloakify,
...resolvedViteConfig?.userProvidedBuildOptions
...resolvedViteConfig?.buildOptions
};
const themeNames = (() => {
if (userProvidedBuildOptions.themeName === undefined) {
const themeNames = ((): [string, ...string[]] => {
if (buildOptions.themeName === undefined) {
return [
parsedPackageJson.name
.replace(/^@(.*)/, "$1")
@ -157,34 +157,38 @@ export function readBuildOptions(params: {
];
}
if (typeof userProvidedBuildOptions.themeName === "string") {
return [userProvidedBuildOptions.themeName];
if (typeof buildOptions.themeName === "string") {
return [buildOptions.themeName];
}
return userProvidedBuildOptions.themeName;
const [mainThemeName, ...themeVariantNames] = buildOptions.themeName;
assert(mainThemeName !== undefined);
return [mainThemeName, ...themeVariantNames];
})();
const reactAppBuildDirPath = (() => {
const projectBuildDirPath = (() => {
webpack: {
if (resolvedViteConfig !== undefined) {
break webpack;
}
if (parsedPackageJson.keycloakify?.reactAppBuildDirPath !== undefined) {
if (parsedPackageJson.keycloakify?.projectBuildDirPath !== undefined) {
return getAbsoluteAndInOsFormatPath({
pathIsh: parsedPackageJson.keycloakify.reactAppBuildDirPath,
cwd: reactAppRootDirPath
pathIsh: parsedPackageJson.keycloakify.projectBuildDirPath,
cwd: projectDirPath
});
}
return pathJoin(reactAppRootDirPath, "build");
return pathJoin(projectDirPath, "build");
}
return pathJoin(reactAppRootDirPath, resolvedViteConfig.buildDir);
return pathJoin(projectDirPath, resolvedViteConfig.buildDir);
})();
const { npmWorkspaceRootDirPath } = getNpmWorkspaceRootDirPath({
reactAppRootDirPath,
projectDirPath,
dependencyExpected: "keycloakify"
});
@ -193,13 +197,13 @@ export function readBuildOptions(params: {
themeVersion:
process.env.KEYCLOAKIFY_THEME_VERSION ?? parsedPackageJson.version ?? "0.0.0",
themeNames,
extraThemeProperties: userProvidedBuildOptions.extraThemeProperties,
extraThemeProperties: buildOptions.extraThemeProperties,
groupId: (() => {
const fallbackGroupId = `${themeNames[0]}.keycloak`;
return (
process.env.KEYCLOAKIFY_GROUP_ID ??
userProvidedBuildOptions.groupId ??
buildOptions.groupId ??
(parsedPackageJson.homepage === undefined
? fallbackGroupId
: urlParse(parsedPackageJson.homepage)
@ -211,22 +215,22 @@ export function readBuildOptions(params: {
})(),
artifactId:
process.env.KEYCLOAKIFY_ARTIFACT_ID ??
userProvidedBuildOptions.artifactId ??
buildOptions.artifactId ??
`${themeNames[0]}-keycloak-theme`,
loginThemeResourcesFromKeycloakVersion:
userProvidedBuildOptions.loginThemeResourcesFromKeycloakVersion ?? "24.0.4",
reactAppRootDirPath,
reactAppBuildDirPath,
buildOptions.loginThemeResourcesFromKeycloakVersion ?? "24.0.4",
projectDirPath,
projectBuildDirPath,
keycloakifyBuildDirPath: (() => {
if (userProvidedBuildOptions.keycloakifyBuildDirPath !== undefined) {
if (buildOptions.keycloakifyBuildDirPath !== undefined) {
return getAbsoluteAndInOsFormatPath({
pathIsh: userProvidedBuildOptions.keycloakifyBuildDirPath,
cwd: reactAppRootDirPath
pathIsh: buildOptions.keycloakifyBuildDirPath,
cwd: projectDirPath
});
}
return pathJoin(
reactAppRootDirPath,
projectDirPath,
resolvedViteConfig?.buildDir === undefined
? "build_keycloak"
: `${resolvedViteConfig.buildDir}_keycloak`
@ -241,14 +245,14 @@ export function readBuildOptions(params: {
if (process.env.PUBLIC_DIR_PATH !== undefined) {
return getAbsoluteAndInOsFormatPath({
pathIsh: process.env.PUBLIC_DIR_PATH,
cwd: reactAppRootDirPath
cwd: projectDirPath
});
}
return pathJoin(reactAppRootDirPath, "public");
return pathJoin(projectDirPath, "public");
}
return pathJoin(reactAppRootDirPath, resolvedViteConfig.publicDir);
return pathJoin(projectDirPath, resolvedViteConfig.publicDir);
})(),
cacheDirPath: (() => {
const cacheDirPath = pathJoin(
@ -297,11 +301,28 @@ export function readBuildOptions(params: {
break webpack;
}
return pathJoin(reactAppBuildDirPath, "static");
return pathJoin(projectBuildDirPath, "static");
}
return pathJoin(reactAppBuildDirPath, resolvedViteConfig.assetsDir);
return pathJoin(projectBuildDirPath, resolvedViteConfig.assetsDir);
})(),
npmWorkspaceRootDirPath
npmWorkspaceRootDirPath,
kcContextExclusionsFtlCode: (() => {
if (buildOptions.kcContextExclusionsFtl === undefined) {
return undefined;
}
if (buildOptions.kcContextExclusionsFtl.endsWith(".ftl")) {
const kcContextExclusionsFtlPath = getAbsoluteAndInOsFormatPath({
pathIsh: buildOptions.kcContextExclusionsFtl,
cwd: projectDirPath
});
return fs.readFileSync(kcContextExclusionsFtlPath).toString("utf8");
}
return buildOptions.kcContextExclusionsFtl;
})(),
environmentVariables: buildOptions.environmentVariables ?? []
};
}

View File

@ -16,7 +16,7 @@ export const vitePluginSubScriptEnvNames = {
resolveViteConfig: "KEYCLOAKIFY_RESOLVE_VITE_CONFIG"
} as const;
export const skipBuildJarsEnvName = "KEYCLOAKIFY_SKIP_BUILD_JAR";
export const onlyBuildJarFileBasenameEnvName = "KEYCLOAKIFY_ONLY_BUILD_JAR_FILE_BASENAME";
export const loginThemePageIds = [
"login.ftl",

View File

@ -1,6 +1,6 @@
import {
downloadKeycloakStaticResources,
type BuildOptionsLike as BuildOptionsLike_downloadKeycloakStaticResources
type BuildContextLike as BuildContextLike_downloadKeycloakStaticResources
} from "./downloadKeycloakStaticResources";
import { join as pathJoin, relative as pathRelative } from "path";
import {
@ -12,21 +12,21 @@ import { readThisNpmPackageVersion } from "../tools/readThisNpmPackageVersion";
import { assert } from "tsafe/assert";
import * as fs from "fs";
import { rmSync } from "../tools/fs.rmSync";
import type { BuildOptions } from "./buildOptions";
import type { BuildContext } from "./buildContext";
export type BuildOptionsLike = BuildOptionsLike_downloadKeycloakStaticResources & {
export type BuildContextLike = BuildContextLike_downloadKeycloakStaticResources & {
loginThemeResourcesFromKeycloakVersion: string;
publicDirPath: string;
};
assert<BuildOptions extends BuildOptionsLike ? true : false>();
assert<BuildContext extends BuildContextLike ? true : false>();
export async function copyKeycloakResourcesToPublic(params: {
buildOptions: BuildOptionsLike;
buildContext: BuildContextLike;
}) {
const { buildOptions } = params;
const { buildContext } = params;
const destDirPath = pathJoin(buildOptions.publicDirPath, keycloak_resources);
const destDirPath = pathJoin(buildContext.publicDirPath, keycloak_resources);
const keycloakifyBuildinfoFilePath = pathJoin(destDirPath, "keycloakify.buildinfo");
@ -34,12 +34,12 @@ export async function copyKeycloakResourcesToPublic(params: {
{
destDirPath,
keycloakifyVersion: readThisNpmPackageVersion(),
buildOptions: {
buildContext: {
loginThemeResourcesFromKeycloakVersion: readThisNpmPackageVersion(),
cacheDirPath: pathRelative(destDirPath, buildOptions.cacheDirPath),
cacheDirPath: pathRelative(destDirPath, buildContext.cacheDirPath),
npmWorkspaceRootDirPath: pathRelative(
destDirPath,
buildOptions.npmWorkspaceRootDirPath
buildContext.npmWorkspaceRootDirPath
)
}
},
@ -74,14 +74,14 @@ export async function copyKeycloakResourcesToPublic(params: {
keycloakVersion: (() => {
switch (themeType) {
case "login":
return buildOptions.loginThemeResourcesFromKeycloakVersion;
return buildContext.loginThemeResourcesFromKeycloakVersion;
case "account":
return lastKeycloakVersionWithAccountV1;
}
})(),
themeType,
themeDirPath: destDirPath,
buildOptions
buildContext
});
}

View File

@ -1,27 +1,27 @@
import { join as pathJoin, relative as pathRelative } from "path";
import { type BuildOptions } from "./buildOptions";
import { type BuildContext } from "./buildContext";
import { assert } from "tsafe/assert";
import { lastKeycloakVersionWithAccountV1 } from "./constants";
import { downloadAndExtractArchive } from "../tools/downloadAndExtractArchive";
import { isInside } from "../tools/isInside";
export type BuildOptionsLike = {
export type BuildContextLike = {
cacheDirPath: string;
npmWorkspaceRootDirPath: string;
};
assert<BuildOptions extends BuildOptionsLike ? true : false>();
assert<BuildContext extends BuildContextLike ? true : false>();
export async function downloadKeycloakDefaultTheme(params: {
keycloakVersion: string;
buildOptions: BuildOptionsLike;
buildContext: BuildContextLike;
}): Promise<{ defaultThemeDirPath: string }> {
const { keycloakVersion, buildOptions } = params;
const { keycloakVersion, buildContext } = params;
const { extractedDirPath } = await downloadAndExtractArchive({
url: `https://repo1.maven.org/maven2/org/keycloak/keycloak-themes/${keycloakVersion}/keycloak-themes-${keycloakVersion}.jar`,
cacheDirPath: buildOptions.cacheDirPath,
npmWorkspaceRootDirPath: buildOptions.npmWorkspaceRootDirPath,
cacheDirPath: buildContext.cacheDirPath,
npmWorkspaceRootDirPath: buildContext.npmWorkspaceRootDirPath,
uniqueIdOfOnOnArchiveFile: "downloadKeycloakDefaultTheme",
onArchiveFile: async params => {
if (!isInside({ dirPath: "theme", filePath: params.fileRelativePath })) {

View File

@ -2,28 +2,28 @@ import { transformCodebase } from "../tools/transformCodebase";
import { join as pathJoin } from "path";
import {
downloadKeycloakDefaultTheme,
type BuildOptionsLike as BuildOptionsLike_downloadKeycloakDefaultTheme
type BuildContextLike as BuildContextLike_downloadKeycloakDefaultTheme
} from "./downloadKeycloakDefaultTheme";
import { resources_common, type ThemeType } from "./constants";
import type { BuildOptions } from "./buildOptions";
import type { BuildContext } from "./buildContext";
import { assert } from "tsafe/assert";
import { existsAsync } from "../tools/fs.existsAsync";
export type BuildOptionsLike = BuildOptionsLike_downloadKeycloakDefaultTheme & {};
export type BuildContextLike = BuildContextLike_downloadKeycloakDefaultTheme & {};
assert<BuildOptions extends BuildOptionsLike ? true : false>();
assert<BuildContext extends BuildContextLike ? true : false>();
export async function downloadKeycloakStaticResources(params: {
themeType: ThemeType;
themeDirPath: string;
keycloakVersion: string;
buildOptions: BuildOptionsLike;
buildContext: BuildContextLike;
}) {
const { themeType, themeDirPath, keycloakVersion, buildOptions } = params;
const { themeType, themeDirPath, keycloakVersion, buildContext } = params;
const { defaultThemeDirPath } = await downloadKeycloakDefaultTheme({
keycloakVersion,
buildOptions
buildContext
});
const resourcesDirPath = pathJoin(themeDirPath, themeType, "resources");

View File

@ -0,0 +1,72 @@
import { assert } from "tsafe/assert";
import type { BuildContext } from "./buildContext";
import { getThemeSrcDirPath } from "./getThemeSrcDirPath";
import * as fs from "fs/promises";
import { join as pathJoin } from "path";
import { existsAsync } from "../tools/fs.existsAsync";
export type BuildContextLike = {
projectDirPath: string;
themeNames: string[];
environmentVariables: { name: string; default: string }[];
};
assert<BuildContext extends BuildContextLike ? true : false>();
export async function generateKcGenTs(params: {
buildContext: BuildContextLike;
}): Promise<void> {
const { buildContext } = params;
const { themeSrcDirPath } = getThemeSrcDirPath({
projectDirPath: buildContext.projectDirPath
});
const filePath = pathJoin(themeSrcDirPath, "kc.gen.ts");
const currentContent = (await existsAsync(filePath))
? await fs.readFile(filePath)
: undefined;
const newContent = Buffer.from(
[
`/* prettier-ignore-start */`,
``,
`/* eslint-disable */`,
``,
`// @ts-nocheck`,
``,
`// noinspection JSUnusedGlobalSymbols`,
``,
`// This file is auto-generated by Keycloakify`,
``,
`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
)};`,
``,
`/* prettier-ignore-end */`,
``
].join("\n"),
"utf8"
);
if (currentContent !== undefined && currentContent.equals(newContent)) {
return;
}
await fs.writeFile(filePath, newContent);
}

View File

@ -7,10 +7,10 @@ import { themeTypes } from "./constants";
const themeSrcDirBasenames = ["keycloak-theme", "keycloak_theme"];
/** Can't catch error, if the directory isn't found, this function will just exit the process with an error message. */
export function getThemeSrcDirPath(params: { reactAppRootDirPath: string }) {
const { reactAppRootDirPath } = params;
export function getThemeSrcDirPath(params: { projectDirPath: string }) {
const { projectDirPath } = params;
const srcDirPath = pathJoin(reactAppRootDirPath, "src");
const srcDirPath = pathJoin(projectDirPath, "src");
const themeSrcDirPath: string | undefined = crawl({
dirPath: srcDirPath,

View File

@ -1,50 +1,73 @@
import { join as pathJoin, dirname as pathDirname } from "path";
import type { ThemeType } from "./constants";
import * as fs from "fs";
import { assert } from "tsafe/assert";
import { extractArchive } from "../tools/extractArchive";
export type MetaInfKeycloakTheme = {
themes: { name: string; types: (ThemeType | "email")[] }[];
};
export function getMetaInfKeycloakThemesJsonFilePath(params: {
keycloakifyBuildDirPath: string;
resourcesDirPath: string;
}) {
const { keycloakifyBuildDirPath } = params;
const { resourcesDirPath } = params;
return pathJoin(
keycloakifyBuildDirPath === "." ? "" : keycloakifyBuildDirPath,
"src",
"main",
"resources",
resourcesDirPath === "." ? "" : resourcesDirPath,
"META-INF",
"keycloak-themes.json"
);
}
export function readMetaInfKeycloakThemes(params: {
keycloakifyBuildDirPath: string;
}): MetaInfKeycloakTheme {
const { keycloakifyBuildDirPath } = params;
export function readMetaInfKeycloakThemes_fromResourcesDirPath(params: {
resourcesDirPath: string;
}) {
const { resourcesDirPath } = params;
return JSON.parse(
fs
.readFileSync(
getMetaInfKeycloakThemesJsonFilePath({
keycloakifyBuildDirPath
resourcesDirPath
})
)
.toString("utf8")
) as MetaInfKeycloakTheme;
}
export async function readMetaInfKeycloakThemes_fromJar(params: {
jarFilePath: string;
}): Promise<MetaInfKeycloakTheme> {
const { jarFilePath } = params;
let metaInfKeycloakThemes: MetaInfKeycloakTheme | undefined = undefined;
await extractArchive({
archiveFilePath: jarFilePath,
onArchiveFile: async ({ relativeFilePathInArchive, readFile, earlyExit }) => {
if (
relativeFilePathInArchive ===
getMetaInfKeycloakThemesJsonFilePath({ resourcesDirPath: "." })
) {
metaInfKeycloakThemes = JSON.parse((await readFile()).toString("utf8"));
earlyExit();
}
}
});
assert(metaInfKeycloakThemes !== undefined);
return metaInfKeycloakThemes;
}
export function writeMetaInfKeycloakThemes(params: {
keycloakifyBuildDirPath: string;
resourcesDirPath: string;
metaInfKeycloakThemes: MetaInfKeycloakTheme;
}) {
const { keycloakifyBuildDirPath, metaInfKeycloakThemes } = params;
const { resourcesDirPath, metaInfKeycloakThemes } = params;
const metaInfKeycloakThemesJsonPath = getMetaInfKeycloakThemesJsonFilePath({
keycloakifyBuildDirPath
resourcesDirPath
});
{

View File

@ -9,9 +9,10 @@ import { id } from "tsafe/id";
export async function promptKeycloakVersion(params: {
startingFromMajor: number | undefined;
excludeMajorVersions: number[];
cacheDirPath: string;
}) {
const { startingFromMajor, cacheDirPath } = params;
const { startingFromMajor, excludeMajorVersions, cacheDirPath } = params;
const { getLatestsSemVersionedTag } = (() => {
const { octokit } = (() => {
@ -95,6 +96,10 @@ export async function promptKeycloakVersion(params: {
return;
}
if (excludeMajorVersions.includes(semVersionedTag.version.major)) {
return;
}
const currentSemVersionedTag = semVersionedTagByMajor.get(
semVersionedTag.version.major
);

View File

@ -2,26 +2,26 @@ import * as child_process from "child_process";
import { Deferred } from "evt/tools/Deferred";
import { assert } from "tsafe/assert";
import { is } from "tsafe/is";
import type { BuildOptions } from "../shared/buildOptions";
import type { BuildContext } from "../shared/buildContext";
import * as fs from "fs";
import { join as pathJoin } from "path";
export type BuildOptionsLike = {
reactAppRootDirPath: string;
export type BuildContextLike = {
projectDirPath: string;
keycloakifyBuildDirPath: string;
bundler: "vite" | "webpack";
npmWorkspaceRootDirPath: string;
reactAppBuildDirPath: string;
projectBuildDirPath: string;
};
assert<BuildOptions extends BuildOptionsLike ? true : false>();
assert<BuildContext extends BuildContextLike ? true : false>();
export async function appBuild(params: {
buildOptions: BuildOptionsLike;
buildContext: BuildContextLike;
}): Promise<{ isAppBuildSuccess: boolean }> {
const { buildOptions } = params;
const { buildContext } = params;
const { bundler } = buildOptions;
const { bundler } = buildContext;
const { command, args, cwd } = (() => {
switch (bundler) {
@ -29,12 +29,12 @@ export async function appBuild(params: {
return {
command: "npx",
args: ["vite", "build"],
cwd: buildOptions.reactAppRootDirPath
cwd: buildContext.projectDirPath
};
case "webpack": {
for (const dirPath of [
buildOptions.reactAppRootDirPath,
buildOptions.npmWorkspaceRootDirPath
buildContext.projectDirPath,
buildContext.npmWorkspaceRootDirPath
]) {
try {
const parsedPackageJson = JSON.parse(

View File

@ -1,31 +1,31 @@
import { skipBuildJarsEnvName } from "../shared/constants";
import { onlyBuildJarFileBasenameEnvName } from "../shared/constants";
import * as child_process from "child_process";
import { Deferred } from "evt/tools/Deferred";
import { assert } from "tsafe/assert";
import type { BuildOptions } from "../shared/buildOptions";
import type { BuildContext } from "../shared/buildContext";
export type BuildOptionsLike = {
reactAppRootDirPath: string;
export type BuildContextLike = {
projectDirPath: string;
keycloakifyBuildDirPath: string;
bundler: "vite" | "webpack";
npmWorkspaceRootDirPath: string;
};
assert<BuildOptions extends BuildOptionsLike ? true : false>();
assert<BuildContext extends BuildContextLike ? true : false>();
export async function keycloakifyBuild(params: {
doSkipBuildJars: boolean;
buildOptions: BuildOptionsLike;
onlyBuildJarFileBasename: string | undefined;
buildContext: BuildContextLike;
}): Promise<{ isKeycloakifyBuildSuccess: boolean }> {
const { buildOptions, doSkipBuildJars } = params;
const { buildContext, onlyBuildJarFileBasename } = params;
const dResult = new Deferred<{ isSuccess: boolean }>();
const child = child_process.spawn("npx", ["keycloakify", "build"], {
cwd: buildOptions.reactAppRootDirPath,
cwd: buildContext.projectDirPath,
env: {
...process.env,
...(doSkipBuildJars ? { [skipBuildJarsEnvName]: "true" } : {})
[onlyBuildJarFileBasenameEnvName]: onlyBuildJarFileBasename
}
});

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -587,7 +587,9 @@
"publicClient": true,
"frontchannelLogout": false,
"protocol": "openid-connect",
"attributes": {},
"attributes": {
"post.logout.redirect.uris": "+"
},
"authenticationFlowBindingOverrides": {},
"fullScopeAllowed": false,
"nodeReRegistrationTimeout": 0,
@ -619,7 +621,9 @@
"publicClient": false,
"frontchannelLogout": false,
"protocol": "openid-connect",
"attributes": {},
"attributes": {
"post.logout.redirect.uris": "+"
},
"authenticationFlowBindingOverrides": {},
"fullScopeAllowed": false,
"nodeReRegistrationTimeout": 0,
@ -695,7 +699,9 @@
"publicClient": false,
"frontchannelLogout": false,
"protocol": "openid-connect",
"attributes": {},
"attributes": {
"post.logout.redirect.uris": "+"
},
"authenticationFlowBindingOverrides": {},
"fullScopeAllowed": false,
"nodeReRegistrationTimeout": 0,
@ -783,6 +789,7 @@
"config": {
"introspection.token.claim": "true",
"multivalued": "true",
"userinfo.token.claim": "true",
"user.attribute": "foo",
"id.token.claim": "true",
"access.token.claim": "true",
@ -827,7 +834,8 @@
"config": {
"id.token.claim": "true",
"introspection.token.claim": "true",
"access.token.claim": "true"
"access.token.claim": "true",
"userinfo.token.claim": "true"
}
}
]
@ -1348,10 +1356,10 @@
"config": {
"allowed-protocol-mapper-types": [
"oidc-sha256-pairwise-sub-mapper",
"oidc-address-mapper",
"saml-user-property-mapper",
"oidc-full-name-mapper",
"oidc-usermodel-attribute-mapper",
"saml-user-property-mapper",
"oidc-address-mapper",
"saml-role-list-mapper",
"saml-user-attribute-mapper",
"oidc-usermodel-property-mapper"
@ -1423,13 +1431,13 @@
"subComponents": {},
"config": {
"allowed-protocol-mapper-types": [
"saml-user-property-mapper",
"oidc-full-name-mapper",
"saml-user-attribute-mapper",
"oidc-sha256-pairwise-sub-mapper",
"oidc-usermodel-attribute-mapper",
"oidc-address-mapper",
"saml-role-list-mapper",
"oidc-full-name-mapper",
"saml-user-property-mapper",
"oidc-address-mapper",
"oidc-usermodel-attribute-mapper",
"oidc-usermodel-property-mapper"
]
}
@ -2043,7 +2051,7 @@
"name": "Terms and Conditions",
"providerId": "TERMS_AND_CONDITIONS",
"enabled": true,
"defaultAction": false,
"defaultAction": true,
"priority": 20,
"config": {}
},
@ -2122,8 +2130,8 @@
"cibaExpiresIn": "120",
"cibaAuthRequestedUserHint": "login_hint",
"oauth2DeviceCodeLifespan": "600",
"oauth2DevicePollingInterval": "5",
"clientOfflineSessionMaxLifespan": "0",
"oauth2DevicePollingInterval": "5",
"clientSessionIdleTimeout": "0",
"parRequestUriLifespan": "60",
"clientSessionMaxLifespan": "0",

View File

@ -1501,14 +1501,14 @@
"subComponents": {},
"config": {
"allowed-protocol-mapper-types": [
"oidc-sha256-pairwise-sub-mapper",
"saml-role-list-mapper",
"oidc-address-mapper",
"oidc-usermodel-property-mapper",
"oidc-sha256-pairwise-sub-mapper",
"saml-user-attribute-mapper",
"oidc-usermodel-attribute-mapper",
"oidc-full-name-mapper",
"saml-user-property-mapper",
"oidc-usermodel-attribute-mapper"
"saml-user-property-mapper"
]
}
},
@ -1541,13 +1541,13 @@
"config": {
"allowed-protocol-mapper-types": [
"oidc-sha256-pairwise-sub-mapper",
"saml-user-property-mapper",
"oidc-usermodel-attribute-mapper",
"oidc-address-mapper",
"oidc-usermodel-property-mapper",
"oidc-full-name-mapper",
"oidc-address-mapper",
"oidc-usermodel-attribute-mapper",
"saml-user-property-mapper",
"saml-role-list-mapper",
"saml-user-attribute-mapper",
"oidc-full-name-mapper"
"saml-user-attribute-mapper"
]
}
},
@ -2200,7 +2200,7 @@
"name": "Terms and Conditions",
"providerId": "TERMS_AND_CONDITIONS",
"enabled": true,
"defaultAction": false,
"defaultAction": true,
"priority": 20,
"config": {}
},
@ -2307,7 +2307,7 @@
"cibaInterval": "5",
"realmReusableOtpCode": "false"
},
"keycloakVersion": "24.0.4",
"keycloakVersion": "24.0.5",
"userManagedAccessAllowed": false,
"clientProfiles": {
"profiles": []

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,8 @@
import { readBuildOptions } from "../shared/buildOptions";
import { getBuildContext } from "../shared/buildContext";
import { exclude } from "tsafe/exclude";
import type { CliCommandOptions as CliCommandOptions_common } from "../main";
import { promptKeycloakVersion } from "../shared/promptKeycloakVersion";
import { readMetaInfKeycloakThemes } from "../shared/metaInfKeycloakThemes";
import { readMetaInfKeycloakThemes_fromJar } from "../shared/metaInfKeycloakThemes";
import { accountV1ThemeName, containerName } from "../shared/constants";
import { SemVer } from "../tools/SemVer";
import type { KeycloakVersionRange } from "../shared/KeycloakVersionRange";
@ -20,6 +21,9 @@ import * as runExclusive from "run-exclusive";
import { extractArchive } from "../tools/extractArchive";
import { appBuild } from "./appBuild";
import { keycloakifyBuild } from "./keycloakifyBuild";
import { isInside } from "../tools/isInside";
import { existsAsync } from "../tools/fs.existsAsync";
import { rm } from "../tools/fs.rm";
export type CliCommandOptions = CliCommandOptions_common & {
port: number;
@ -80,11 +84,11 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
const { cliCommandOptions } = params;
const buildOptions = readBuildOptions({ cliCommandOptions });
const buildContext = getBuildContext({ cliCommandOptions });
{
const { isAppBuildSuccess } = await appBuild({
buildOptions
buildContext
});
if (!isAppBuildSuccess) {
@ -97,8 +101,8 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
}
const { isKeycloakifyBuildSuccess } = await keycloakifyBuild({
doSkipBuildJars: false,
buildOptions
onlyBuildJarFileBasename: undefined,
buildContext
});
if (!isKeycloakifyBuildSuccess) {
@ -111,13 +115,31 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
}
}
const metaInfKeycloakThemes = readMetaInfKeycloakThemes({
keycloakifyBuildDirPath: buildOptions.keycloakifyBuildDirPath
});
const { doesImplementAccountTheme } = await (async () => {
const latestJarFilePath = fs
.readdirSync(buildContext.keycloakifyBuildDirPath)
.filter(fileBasename => fileBasename.endsWith(".jar"))
.map(fileBasename =>
pathJoin(buildContext.keycloakifyBuildDirPath, fileBasename)
)
.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs)[0];
const doesImplementAccountTheme = metaInfKeycloakThemes.themes.some(
({ name }) => name === accountV1ThemeName
);
assert(latestJarFilePath !== undefined);
const metaInfKeycloakThemes = await readMetaInfKeycloakThemes_fromJar({
jarFilePath: latestJarFilePath
});
const mainThemeEntry = metaInfKeycloakThemes.themes.find(
({ name }) => name === buildContext.themeNames[0]
);
assert(mainThemeEntry !== undefined);
const doesImplementAccountTheme = mainThemeEntry.types.includes("account");
return { doesImplementAccountTheme };
})();
const { keycloakVersion, keycloakMajorNumber: keycloakMajorVersionNumber } =
await (async function getKeycloakMajor(): Promise<{
@ -137,8 +159,9 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
);
const { keycloakVersion } = await promptKeycloakVersion({
startingFromMajor: 17,
cacheDirPath: buildOptions.cacheDirPath
startingFromMajor: 18,
excludeMajorVersions: [22],
cacheDirPath: buildContext.cacheDirPath
});
console.log(`${keycloakVersion}`);
@ -171,7 +194,11 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
return "23" as const;
}
return "24-and-above" as const;
if (keycloakMajorVersionNumber === 24) {
return "24" as const;
}
return "25-and-above" as const;
})();
assert<
@ -205,6 +232,10 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
const realmJsonFilePath = await (async () => {
if (cliCommandOptions.realmJsonFilePath !== undefined) {
if (cliCommandOptions.realmJsonFilePath === "none") {
return undefined;
}
console.log(
chalk.green(
`Using realm json file: ${cliCommandOptions.realmJsonFilePath}`
@ -259,67 +290,36 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
return pathJoin(dirPath, value);
})();
const jarFilePath = pathJoin(buildOptions.keycloakifyBuildDirPath, jarFileBasename);
const { doUseBuiltInAccountV1Theme } = await (async () => {
let doUseBuiltInAccountV1Theme = false;
const jarFilePath = pathJoin(buildContext.keycloakifyBuildDirPath, jarFileBasename);
async function extractThemeResourcesFromJar() {
await extractArchive({
archiveFilePath: jarFilePath,
onArchiveFile: async ({ relativeFilePathInArchive, readFile, earlyExit }) => {
for (const themeName of buildOptions.themeNames) {
if (
relativeFilePathInArchive ===
pathJoin("theme", themeName, "account", "theme.properties")
) {
if (
(await readFile())
.toString("utf8")
.includes("parent=keycloak")
) {
doUseBuiltInAccountV1Theme = true;
}
earlyExit();
}
onArchiveFile: async ({ relativeFilePathInArchive, writeFile }) => {
if (isInside({ dirPath: "theme", filePath: relativeFilePathInArchive })) {
await writeFile({
filePath: pathJoin(
buildContext.keycloakifyBuildDirPath,
relativeFilePathInArchive
)
});
}
}
});
}
return { doUseBuiltInAccountV1Theme };
})();
{
const destDirPath = pathJoin(buildContext.keycloakifyBuildDirPath, "theme");
if (await existsAsync(destDirPath)) {
await rm(destDirPath, { recursive: true });
}
}
const accountThemePropertyPatch = !doUseBuiltInAccountV1Theme
? undefined
: () => {
for (const themeName of buildOptions.themeNames) {
const filePath = pathJoin(
buildOptions.keycloakifyBuildDirPath,
"src",
"main",
"resources",
"theme",
themeName,
"account",
"theme.properties"
);
await extractThemeResourcesFromJar();
const sourceCode = fs.readFileSync(filePath);
const jarFilePath_cacheDir = pathJoin(buildContext.cacheDirPath, jarFileBasename);
const modifiedSourceCode = Buffer.from(
sourceCode
.toString("utf8")
.replace(`parent=${accountV1ThemeName}`, "parent=keycloak"),
"utf8"
);
assert(Buffer.compare(modifiedSourceCode, sourceCode) !== 0);
fs.writeFileSync(filePath, modifiedSourceCode);
}
};
accountThemePropertyPatch?.();
fs.copyFileSync(jarFilePath, jarFilePath_cacheDir);
try {
child_process.execSync(`docker rm --force ${containerName}`, {
@ -341,20 +341,28 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
"-v",
`${realmJsonFilePath}:/opt/keycloak/data/import/myrealm-realm.json`
]),
...["-v", `${jarFilePath}:/opt/keycloak/providers/keycloak-theme.jar`],
...[
"-v",
`${jarFilePath_cacheDir}:/opt/keycloak/providers/keycloak-theme.jar`
],
...(keycloakMajorVersionNumber <= 20
? ["-e", "JAVA_OPTS=-Dkeycloak.profile=preview"]
: []),
...[
...buildOptions.themeNames,
...(doUseBuiltInAccountV1Theme ? [] : [accountV1ThemeName])
...buildContext.themeNames,
...(fs.existsSync(
pathJoin(
buildContext.keycloakifyBuildDirPath,
"theme",
accountV1ThemeName
)
)
? [accountV1ThemeName]
: [])
]
.map(themeName => ({
localDirPath: pathJoin(
buildOptions.keycloakifyBuildDirPath,
"src",
"main",
"resources",
buildContext.keycloakifyBuildDirPath,
"theme",
themeName
),
@ -365,6 +373,17 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
`${localDirPath}:${containerDirPath}:rw`
])
.flat(),
...buildContext.environmentVariables
.map(({ name }) => ({ name, envValue: process.env[name] }))
.map(({ name, envValue }) =>
envValue === undefined ? undefined : { name, envValue }
)
.filter(exclude(undefined))
.map(({ name, envValue }) => [
"--env",
`${name}='${envValue.replace(/'/g, "'\\''")}'`
])
.flat(),
`quay.io/keycloak/keycloak:${keycloakVersion}`,
"start-dev",
...(21 <= keycloakMajorVersionNumber && keycloakMajorVersionNumber < 24
@ -373,7 +392,7 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
...(realmJsonFilePath === undefined ? [] : ["--import-realm"])
],
{
cwd: buildOptions.keycloakifyBuildDirPath
cwd: buildContext.keycloakifyBuildDirPath
}
] as const;
@ -385,7 +404,7 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
child.on("exit", process.exit);
const srcDirPath = pathJoin(buildOptions.reactAppRootDirPath, "src");
const srcDirPath = pathJoin(buildContext.projectDirPath, "src");
{
const handler = async (data: Buffer) => {
@ -417,7 +436,7 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
`- password: ${chalk.cyan.bold("password123")}`,
"",
`Watching for changes in ${chalk.bold(
`.${pathSep}${pathRelative(process.cwd(), srcDirPath)}`
`.${pathSep}${pathRelative(process.cwd(), buildContext.projectDirPath)}`
)}`
].join("\n")
);
@ -431,7 +450,7 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
console.log(chalk.cyan("Detected changes in the theme. Rebuilding ..."));
const { isAppBuildSuccess } = await appBuild({
buildOptions
buildContext
});
if (!isAppBuildSuccess) {
@ -439,15 +458,15 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
}
const { isKeycloakifyBuildSuccess } = await keycloakifyBuild({
doSkipBuildJars: true,
buildOptions
onlyBuildJarFileBasename: jarFileBasename,
buildContext
});
if (!isKeycloakifyBuildSuccess) {
return;
}
accountThemePropertyPatch?.();
await extractThemeResourcesFromJar();
console.log(chalk.green("Theme rebuilt and updated in Keycloak."));
});
@ -455,10 +474,23 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
const { waitForDebounce } = waitForDebounceFactory({ delay: 400 });
chokidar
.watch([srcDirPath, pathJoin(getThisCodebaseRootDirPath(), "src")], {
ignoreInitial: true
})
.on("all", async () => {
.watch(
[
srcDirPath,
buildContext.publicDirPath,
pathJoin(buildContext.projectDirPath, "package.json"),
pathJoin(buildContext.projectDirPath, "vite.config.ts"),
pathJoin(buildContext.projectDirPath, "vite.config.js"),
pathJoin(buildContext.projectDirPath, "index.html"),
pathJoin(getThisCodebaseRootDirPath(), "src")
],
{
ignoreInitial: true
}
)
.on("all", async (...[, filePath]) => {
console.log(`Detected changes in ${filePath}`);
await waitForDebounce();
runFullBuild();

View File

@ -0,0 +1,64 @@
// Convert a JavaScript string to UTF-16 encoding
function toUTF16(codePoint: number): string {
if (codePoint <= 0xffff) {
// BMP character
return "\\u" + codePoint.toString(16).padStart(4, "0");
} else {
// Non-BMP character
codePoint -= 0x10000;
let highSurrogate = (codePoint >> 10) + 0xd800;
let lowSurrogate = (codePoint % 0x400) + 0xdc00;
return (
"\\u" +
highSurrogate.toString(16).padStart(4, "0") +
"\\u" +
lowSurrogate.toString(16).padStart(4, "0")
);
}
}
// Escapes special characters for use in a .properties file
export function escapeStringForPropertiesFile(str: string): string {
let escapedStr = "";
for (const char of [...str]) {
const codePoint = char.codePointAt(0);
if (!codePoint) continue;
switch (char) {
case "\n":
escapedStr += "\\n";
break;
case "\r":
escapedStr += "\\r";
break;
case "\t":
escapedStr += "\\t";
break;
case "\\":
escapedStr += "\\\\";
break;
case ":":
escapedStr += "\\:";
break;
case "=":
escapedStr += "\\=";
break;
case "#":
escapedStr += "\\#";
break;
case "!":
escapedStr += "\\!";
break;
case "'":
escapedStr += "''";
break;
default:
if (codePoint > 0x7f) {
escapedStr += toUTF16(codePoint); // Non-ASCII characters
} else {
escapedStr += char; // ASCII character needs no escape
}
}
}
return escapedStr;
}

View File

@ -4,16 +4,18 @@ import { assert } from "tsafe/assert";
import * as fs from "fs";
export function getNpmWorkspaceRootDirPath(params: {
reactAppRootDirPath: string;
projectDirPath: string;
dependencyExpected: string;
}) {
const { reactAppRootDirPath, dependencyExpected } = params;
const { projectDirPath, dependencyExpected } = params;
const npmWorkspaceRootDirPath = (function callee(depth: number): string {
const cwd = pathResolve(
pathJoin(...[reactAppRootDirPath, ...Array(depth).fill("..")])
pathJoin(...[projectDirPath, ...Array(depth).fill("..")])
);
assert(cwd !== pathSep, "NPM workspace not found");
try {
child_process.execSync("npm config get", {
cwd,
@ -21,48 +23,46 @@ export function getNpmWorkspaceRootDirPath(params: {
});
} catch (error) {
if (String(error).includes("ENOWORKSPACES")) {
assert(cwd !== pathSep, "NPM workspace not found");
return callee(depth + 1);
}
throw error;
}
const { isExpectedDependencyFound } = (() => {
const packageJsonFilePath = pathJoin(cwd, "package.json");
const packageJsonFilePath = pathJoin(cwd, "package.json");
assert(fs.existsSync(packageJsonFilePath));
if (!fs.existsSync(packageJsonFilePath)) {
return callee(depth + 1);
}
const parsedPackageJson = JSON.parse(
fs.readFileSync(packageJsonFilePath).toString("utf8")
);
assert(fs.existsSync(packageJsonFilePath));
let isExpectedDependencyFound = false;
const parsedPackageJson = JSON.parse(
fs.readFileSync(packageJsonFilePath).toString("utf8")
);
for (const dependenciesOrDevDependencies of [
"dependencies",
"devDependencies"
] as const) {
const dependencies = parsedPackageJson[dependenciesOrDevDependencies];
let isExpectedDependencyFound = false;
if (dependencies === undefined) {
continue;
}
for (const dependenciesOrDevDependencies of [
"dependencies",
"devDependencies"
] as const) {
const dependencies = parsedPackageJson[dependenciesOrDevDependencies];
assert(dependencies instanceof Object);
if (dependencies[dependencyExpected] === undefined) {
continue;
}
isExpectedDependencyFound = true;
if (dependencies === undefined) {
continue;
}
return { isExpectedDependencyFound };
})();
assert(dependencies instanceof Object);
if (!isExpectedDependencyFound) {
if (dependencies[dependencyExpected] === undefined) {
continue;
}
isExpectedDependencyFound = true;
}
if (!isExpectedDependencyFound && parsedPackageJson.name !== dependencyExpected) {
return callee(depth + 1);
}

View File

@ -1,10 +1,11 @@
{
"extends": "../../tsproject.json",
"compilerOptions": {
"module": "CommonJS",
"target": "ES5",
"module": "ES2020",
"target": "ES2017",
"esModuleInterop": true,
"lib": ["es2015", "DOM", "ES2019.Object"],
"lib": ["es2015", "ES2019.Object"],
"moduleResolution": "node",
"outDir": "../../dist/bin",
"rootDir": "."
}

13
src/bin/update-kc-gen.ts Normal file
View File

@ -0,0 +1,13 @@
import type { CliCommandOptions } from "./main";
import { getBuildContext } from "./shared/buildContext";
import { generateKcGenTs } from "./shared/generateKcGenTs";
export async function command(params: { cliCommandOptions: CliCommandOptions }) {
const { cliCommandOptions } = params;
const buildContext = getBuildContext({
cliCommandOptions
});
await generateKcGenTs({ buildContext });
}

89
src/lib/getKcClsx.ts Normal file
View File

@ -0,0 +1,89 @@
import type { Param0 } from "tsafe";
import { type CxArg, clsx_withTransform } from "../tools/clsx_withTransform";
import { clsx } from "../tools/clsx";
import { assert } from "tsafe/assert";
import { is } from "tsafe/is";
export function createGetKcClsx<ClassKey extends string>(params: {
defaultClasses: Record<ClassKey, string | undefined>;
}) {
const { defaultClasses } = params;
function areSameParams(
params1: Param0<typeof getKcClsx>,
params2: Param0<typeof getKcClsx>
): boolean {
if (params1.doUseDefaultCss !== params2.doUseDefaultCss) {
return false;
}
if (params1.classes === params2.classes) {
return true;
}
if (params1.classes === undefined || params2.classes === undefined) {
return false;
}
if (Object.keys(params1.classes).length !== Object.keys(params2.classes).length) {
return false;
}
for (const key in params1.classes) {
if (params1.classes[key] !== params2.classes[key]) {
return false;
}
}
return true;
}
let cache:
| {
params: Param0<typeof getKcClsx>;
result: ReturnType<typeof getKcClsx>;
}
| undefined = undefined;
function getKcClsx(params: {
doUseDefaultCss: boolean;
classes: Partial<Record<ClassKey, string>> | undefined;
}): { kcClsx: (...args: CxArg<ClassKey>[]) => string } {
// NOTE: We implement a cache here only so that getClassName can be stable across renders.
// We don't want to use useConstCallback because we want this to be useable outside of React.
use_cache: {
if (cache === undefined) {
break use_cache;
}
if (!areSameParams(cache.params, params)) {
break use_cache;
}
return cache.result;
}
const { classes, doUseDefaultCss } = params;
function kcClsx(...args: CxArg<ClassKey>[]): string {
return clsx_withTransform({
args,
transform: classKey => {
assert(is<ClassKey>(classKey));
return clsx(
classKey,
doUseDefaultCss ? defaultClasses[classKey] : undefined,
classes?.[classKey]
);
}
});
}
cache = { params, result: { kcClsx } };
return { kcClsx };
}
return { getKcClsx };
}

View File

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

View File

@ -1,27 +0,0 @@
import { clsx } from "keycloakify/tools/clsx";
import { useConstCallback } from "keycloakify/tools/useConstCallback";
export function createUseClassName<ClassKey extends string>(params: {
defaultClasses: Record<ClassKey, string | undefined>;
}) {
const { defaultClasses } = params;
function useGetClassName(params: {
doUseDefaultCss: boolean;
classes: Partial<Record<ClassKey, string>> | undefined;
}) {
const { classes, doUseDefaultCss } = params;
const getClassName = useConstCallback((classKey: ClassKey): string => {
return clsx(
classKey,
doUseDefaultCss ? defaultClasses[classKey] : undefined,
classes?.[classKey]
);
});
return { getClassName };
}
return { useGetClassName };
}

View File

@ -1,9 +1,9 @@
import { lazy, Suspense } from "react";
import type { PageProps } from "keycloakify/login/pages/PageProps";
import { assert, type Equals } from "tsafe/assert";
import type { I18n } from "./i18n";
import type { KcContext } from "./kcContext";
import type { LazyOrNot } from "keycloakify/tools/LazyOrNot";
import type { PageProps } from "keycloakify/login/pages/PageProps";
import type { I18n } from "keycloakify/login/i18n";
import type { KcContext } from "keycloakify/login/KcContext";
import type { UserProfileFormFieldsProps } from "keycloakify/login/UserProfileFormFields";
const Login = lazy(() => import("keycloakify/login/pages/Login"));
@ -41,11 +41,12 @@ const LoginResetOtp = lazy(() => import("keycloakify/login/pages/LoginResetOtp")
const LoginX509Info = lazy(() => import("keycloakify/login/pages/LoginX509Info"));
const WebauthnError = lazy(() => import("keycloakify/login/pages/WebauthnError"));
type FallbackProps = PageProps<KcContext, I18n> & {
type DefaultPageProps = PageProps<KcContext, I18n> & {
UserProfileFormFields: LazyOrNot<(props: UserProfileFormFieldsProps) => JSX.Element>;
doMakeUserConfirmPassword: boolean;
};
export default function Fallback(props: FallbackProps) {
export default function DefaultPage(props: DefaultPageProps) {
const { kcContext, ...rest } = props;
return (

View File

@ -3,14 +3,28 @@ import type {
LoginThemePageId,
nameOfTheLocalizationRealmOverridesUserProfileProperty
} from "keycloakify/bin/shared/constants";
import type { ExtractAfterStartingWith } from "keycloakify/tools/ExtractAfterStartingWith";
import type { ValueOf } from "keycloakify/tools/ValueOf";
import { assert } from "tsafe/assert";
import type { Equals } from "tsafe";
import type { MessageKey } from "../i18n/i18n";
type ExtractAfterStartingWith<
Prefix extends string,
StrEnum
> = StrEnum extends `${Prefix}${infer U}` ? U : never;
export type ExtendKcContext<
KcContextExtension extends { properties?: Record<string, string | undefined> },
KcContextExtensionPerPage extends Record<string, Record<string, unknown>>
> = ValueOf<{
[PageId in keyof KcContextExtensionPerPage | KcContext["pageId"]]: Extract<
KcContext,
{ pageId: PageId }
> extends never
? KcContext.Common &
KcContextExtension & {
pageId: PageId;
} & KcContextExtensionPerPage[PageId]
: Extract<KcContext, { pageId: PageId }> &
KcContextExtension &
KcContextExtensionPerPage[PageId];
}>;
/** Take theses type definition with a grain of salt.
* Some values might be undefined on some pages.
@ -138,13 +152,13 @@ export declare namespace KcContext {
getFirstError: (...fieldNames: string[]) => string;
};
properties: Record<string, string | undefined>;
authenticationSession?: {
authSessionId: string;
tabId: string;
ssoLoginInOtherTabsUrl: string;
};
__localizationRealmOverridesUserProfile: Record<string, string>;
properties: {};
__localizationRealmOverridesUserProfile?: Record<string, string>;
};
export type SamlPostForm = Common & {
@ -585,7 +599,6 @@ export declare namespace KcContext {
}
export type UserProfile = {
attributes: Attribute[];
attributesByName: Record<string, Attribute>;
html5DataAnnotations?: Record<string, string>;
};
@ -684,31 +697,31 @@ export type Attribute = {
| "photo";
};
export type Validators = Partial<{
length: Validators.DoIgnoreEmpty & Validators.Range;
integer: Validators.DoIgnoreEmpty & Validators.Range;
email: Validators.DoIgnoreEmpty;
pattern: Validators.DoIgnoreEmpty & Validators.ErrorMessage & { pattern: string };
options: Validators.Options;
multivalued: Validators.DoIgnoreEmpty & Validators.Range;
export type Validators = {
length?: Validators.DoIgnoreEmpty & Validators.Range;
integer?: Validators.DoIgnoreEmpty & Validators.Range;
email?: Validators.DoIgnoreEmpty;
pattern?: Validators.DoIgnoreEmpty & Validators.ErrorMessage & { pattern: string };
options?: Validators.Options;
multivalued?: Validators.DoIgnoreEmpty & Validators.Range;
// NOTE: Following are the validators for which we don't implement client side validation yet
// or for which the validation can't be performed on the client side.
/*
double: Validators.DoIgnoreEmpty & Validators.Range;
"up-immutable-attribute": {};
"up-attribute-required-by-metadata-value": {};
"up-username-has-value": {};
"up-duplicate-username": {};
"up-username-mutation": {};
"up-email-exists-as-username": {};
"up-blank-attribute-value": Validators.ErrorMessage & { "fail-on-null": boolean; };
"up-duplicate-email": {};
"local-date": Validators.DoIgnoreEmpty;
"person-name-prohibited-characters": Validators.DoIgnoreEmpty & Validators.ErrorMessage;
uri: Validators.DoIgnoreEmpty;
"username-prohibited-characters": Validators.DoIgnoreEmpty & Validators.ErrorMessage;
double?: Validators.DoIgnoreEmpty & Validators.Range;
"up-immutable-attribute"?: {};
"up-attribute-required-by-metadata-value"?: {};
"up-username-has-value"?: {};
"up-duplicate-username"?: {};
"up-username-mutation"?: {};
"up-email-exists-as-username"?: {};
"up-blank-attribute-value"?: Validators.ErrorMessage & { "fail-on-null": boolean; };
"up-duplicate-email"?: {};
"local-date"?: Validators.DoIgnoreEmpty;
"person-name-prohibited-characters"?: Validators.DoIgnoreEmpty & Validators.ErrorMessage;
uri?: Validators.DoIgnoreEmpty;
"username-prohibited-characters"?: Validators.DoIgnoreEmpty & Validators.ErrorMessage;
*/
}>;
};
export declare namespace Validators {
export type DoIgnoreEmpty = {
@ -757,9 +770,8 @@ export type PasswordPolicies = {
};
assert<
KcContext.Common extends Record<
typeof nameOfTheLocalizationRealmOverridesUserProfileProperty,
unknown
KcContext.Common extends Partial<
Record<typeof nameOfTheLocalizationRealmOverridesUserProfileProperty, unknown>
>
? true
: false

View File

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

View File

@ -0,0 +1,8 @@
export type {
ExtendKcContext,
KcContext,
Attribute,
PasswordPolicies,
Validators
} from "./KcContext";
export { createGetKcContextMock } from "./getKcContextMock";

View File

@ -1,4 +1,4 @@
import "minimal-polyfills/Object.fromEntries";
import "keycloakify/tools/Object.fromEntries";
import type { KcContext, Attribute } from "./KcContext";
import {
resources_common,
@ -9,75 +9,72 @@ import { id } from "tsafe/id";
import { assert, type Equals } from "tsafe/assert";
import { BASE_URL } from "keycloakify/lib/BASE_URL";
const attributes: Attribute[] = [
{
validators: {
length: {
"ignore.empty.value": true,
min: "3",
max: "255"
}
},
displayName: "${username}",
annotations: {},
required: true,
autocomplete: "username",
readOnly: false,
name: "username",
value: "xxxx"
},
{
validators: {
length: {
max: "255",
"ignore.empty.value": true
},
email: {
"ignore.empty.value": true
},
pattern: {
"ignore.empty.value": true,
pattern: "gmail\\.com$"
}
},
displayName: "${email}",
annotations: {},
required: true,
autocomplete: "email",
readOnly: false,
name: "email"
},
{
validators: {
length: {
max: "255",
"ignore.empty.value": true
}
},
displayName: "${firstName}",
annotations: {},
required: true,
readOnly: false,
name: "firstName"
},
{
validators: {
length: {
max: "255",
"ignore.empty.value": true
}
},
displayName: "${lastName}",
annotations: {},
required: true,
readOnly: false,
name: "lastName"
}
];
const attributesByName = Object.fromEntries(
attributes.map(attribute => [attribute.name, attribute])
) as any;
id<Attribute[]>([
{
validators: {
length: {
"ignore.empty.value": true,
min: "3",
max: "255"
}
},
displayName: "${username}",
annotations: {},
required: true,
autocomplete: "username",
readOnly: false,
name: "username"
},
{
validators: {
length: {
max: "255",
"ignore.empty.value": true
},
email: {
"ignore.empty.value": true
},
pattern: {
"ignore.empty.value": true,
pattern: "gmail\\.com$"
}
},
displayName: "${email}",
annotations: {},
required: true,
autocomplete: "email",
readOnly: false,
name: "email"
},
{
validators: {
length: {
max: "255",
"ignore.empty.value": true
}
},
displayName: "${firstName}",
annotations: {},
required: true,
readOnly: false,
name: "firstName"
},
{
validators: {
length: {
max: "255",
"ignore.empty.value": true
}
},
displayName: "${lastName}",
annotations: {},
required: true,
readOnly: false,
name: "lastName"
}
]).map(attribute => [attribute.name, attribute])
);
const resourcesPath = `${BASE_URL}${keycloak_resources}/login/resources`;
@ -90,12 +87,9 @@ export const kcContextCommonMock: KcContext.Common = {
loginAction: "#",
resourcesPath,
resourcesCommonPath: `${resourcesPath}/${resources_common}`,
loginRestartFlowUrl:
"/auth/realms/myrealm/login-actions/restart?client_id=account&tab_id=HoAx28ja4xg",
loginUrl:
"/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg",
ssoLoginInOtherTabsUrl:
"/auth/realms/myrealm/login-actions/switch?client_id=account&tab_id=HoAx28ja4xg"
loginRestartFlowUrl: "#",
loginUrl: "#",
ssoLoginInOtherTabsUrl: "#"
},
realm: {
name: "myrealm",
@ -116,98 +110,34 @@ export const kcContextCommonMock: KcContext.Common = {
locale: {
supported: [
/* spell-checker: disable */
{
url: "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=de",
label: "Deutsch",
languageTag: "de"
},
{
url: "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=no",
label: "Norsk",
languageTag: "no"
},
{
url: "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=ru",
label: "Русский",
languageTag: "ru"
},
{
url: "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=sv",
label: "Svenska",
languageTag: "sv"
},
{
url: "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=pt-BR",
label: "Português (Brasil)",
languageTag: "pt-BR"
},
{
url: "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=lt",
label: "Lietuvių",
languageTag: "lt"
},
{
url: "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=en",
label: "English",
languageTag: "en"
},
{
url: "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=it",
label: "Italiano",
languageTag: "it"
},
{
url: "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=fr",
label: "Français",
languageTag: "fr"
},
{
url: "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=zh-CN",
label: "中文简体",
languageTag: "zh-CN"
},
{
url: "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=es",
label: "Español",
languageTag: "es"
},
{
url: "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=cs",
label: "Čeština",
languageTag: "cs"
},
{
url: "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=ja",
label: "日本語",
languageTag: "ja"
},
{
url: "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=sk",
label: "Slovenčina",
languageTag: "sk"
},
{
url: "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=pl",
label: "Polski",
languageTag: "pl"
},
{
url: "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=ca",
label: "Català",
languageTag: "ca"
},
{
url: "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=nl",
label: "Nederlands",
languageTag: "nl"
},
{
url: "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=tr",
label: "Türkçe",
languageTag: "tr"
}
["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
),
currentLanguageTag: "en"
},
auth: {
@ -227,13 +157,10 @@ export const kcContextCommonMock: KcContext.Common = {
const loginUrl = {
...kcContextCommonMock.url,
loginResetCredentialsUrl:
"/auth/realms/myrealm/login-actions/reset-credentials?client_id=account&tab_id=HoAx28ja4xg",
registrationUrl:
"/auth/realms/myrealm/login-actions/registration?client_id=account&tab_id=HoAx28ja4xg",
oauth2DeviceVerificationAction: "/auth/realms/myrealm/device",
oauthAction:
"/auth/realms/myrealm/login-actions/consent?client_id=account&tab_id=HoAx28ja4xg"
loginResetCredentialsUrl: "#",
registrationUrl: "#",
oauth2DeviceVerificationAction: "#",
oauthAction: "#"
};
export const kcContextMocks = [
@ -261,15 +188,13 @@ export const kcContextMocks = [
...kcContextCommonMock,
url: {
...loginUrl,
registrationAction:
"http://localhost:8080/auth/realms/myrealm/login-actions/registration?session_code=gwZdUeO7pbYpFTRxiIxRg_QtzMbtFTKrNu6XW_f8asM&execution=12146ce0-b139-4bbd-b25b-0eccfee6577e&client_id=account&tab_id=uS8lYfebLa0"
registrationAction: "#"
},
isAppInitiatedAction: false,
passwordRequired: true,
recaptchaRequired: false,
pageId: "register.ftl",
profile: {
attributes,
attributesByName
},
scripts: [
@ -421,7 +346,6 @@ export const kcContextMocks = [
...kcContextCommonMock,
pageId: "login-update-profile.ftl",
profile: {
attributes,
attributesByName
}
}),
@ -478,7 +402,6 @@ export const kcContextMocks = [
...kcContextCommonMock,
pageId: "idp-review-user-profile.ftl",
profile: {
attributes,
attributesByName
}
}),
@ -486,12 +409,9 @@ export const kcContextMocks = [
...kcContextCommonMock,
pageId: "update-email.ftl",
profile: {
attributes: attributes.filter(attribute => attribute.name === "email"),
attributesByName: Object.fromEntries(
attributes
.filter(attribute => attribute.name === "email")
.map(attribute => [attribute.name, attribute])
)
attributesByName: {
email: attributesByName["email"]
}
}
}),
id<KcContext.SelectAuthenticator>({
@ -518,7 +438,7 @@ export const kcContextMocks = [
...kcContextCommonMock,
pageId: "saml-post-form.ftl",
samlPost: {
url: ""
url: "#"
}
}),
id<KcContext.LoginPageExpired>({

View File

@ -1,16 +1,13 @@
import { useEffect } from "react";
import { assert } from "keycloakify/tools/assert";
import { clsx } from "keycloakify/tools/clsx";
import { type TemplateProps } from "keycloakify/login/TemplateProps";
import { useGetClassName } from "keycloakify/login/lib/useGetClassName";
import { createUseInsertScriptTags } from "keycloakify/tools/useInsertScriptTags";
import { createUseInsertLinkTags } from "keycloakify/tools/useInsertLinkTags";
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 type { KcContext } from "./kcContext";
import type { I18n } from "./i18n";
const { useInsertLinkTags } = createUseInsertLinkTags();
const { useInsertScriptTags } = createUseInsertScriptTags();
import type { KcContext } from "./KcContext";
export default function Template(props: TemplateProps<KcContext, I18n>) {
const {
@ -30,7 +27,7 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
children
} = props;
const { getClassName } = useGetClassName({ doUseDefaultCss, classes });
const { kcClsx } = getKcClsx({ doUseDefaultCss, classes });
const { msg, msgStr, getChangeLocalUrl, labelBySupportedLanguageTag, currentLanguageTag } = i18n;
@ -42,12 +39,12 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
useSetClassName({
qualifiedName: "html",
className: getClassName("kcHtmlClass")
className: kcClsx("kcHtmlClass")
});
useSetClassName({
qualifiedName: "body",
className: bodyClassName ?? getClassName("kcBodyClass")
className: bodyClassName ?? kcClsx("kcBodyClass")
});
useEffect(() => {
@ -63,6 +60,7 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
}, []);
const { areAllStyleSheetsLoaded } = useInsertLinkTags({
componentOrHookName: "Template",
hrefs: !doUseDefaultCss
? []
: [
@ -75,6 +73,7 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
});
const { insertScriptTags } = useInsertScriptTags({
componentOrHookName: "Template",
scriptTags: [
{
type: "module",
@ -117,24 +116,23 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
}
return (
<div className={getClassName("kcLoginClass")}>
<div id="kc-header" className={getClassName("kcHeaderClass")}>
<div id="kc-header-wrapper" className={getClassName("kcHeaderWrapperClass")}>
<div className={kcClsx("kcLoginClass")}>
<div id="kc-header" className={kcClsx("kcHeaderClass")}>
<div id="kc-header-wrapper" className={kcClsx("kcHeaderWrapperClass")}>
{msg("loginTitleHtml", realm.displayNameHtml)}
</div>
</div>
<div className={getClassName("kcFormCardClass")}>
<header className={getClassName("kcFormHeaderClass")}>
<div className={kcClsx("kcFormCardClass")}>
<header className={kcClsx("kcFormHeaderClass")}>
{realm.internationalizationEnabled && (assert(locale !== undefined), locale.supported.length > 1) && (
<div className={getClassName("kcLocaleMainClass")} id="kc-locale">
<div id="kc-locale-wrapper" className={getClassName("kcLocaleWrapperClass")}>
<div id="kc-locale-dropdown" className={clsx("menu-button-links", getClassName("kcLocaleDropDownClass"))}>
{/* eslint-disable-next-line jsx-a11y/anchor-is-valid */}
<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"))}>
<button
tabIndex={1}
id="kc-current-locale-link"
aria-label={msgStr("languages" as any)}
aria-label={msgStr("languages")}
aria-haspopup="true"
aria-expanded="false"
aria-controls="language-switch1"
@ -147,14 +145,14 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
aria-labelledby="kc-current-locale-link"
aria-activedescendant=""
id="language-switch1"
className={getClassName("kcLocaleListClass")}
className={kcClsx("kcLocaleListClass")}
>
{locale.supported.map(({ languageTag }, i) => (
<li key={languageTag} className={getClassName("kcLocaleListItemClass")} role="none">
<li key={languageTag} className={kcClsx("kcLocaleListItemClass")} role="none">
<a
role="menuitem"
id={`language-${i + 1}`}
className={getClassName("kcLocaleItemClass")}
className={kcClsx("kcLocaleItemClass")}
href={getChangeLocalUrl(languageTag)}
>
{labelBySupportedLanguageTag[languageTag]}
@ -168,8 +166,8 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
)}
{!(auth !== undefined && auth.showUsername && !auth.showResetCredentials) ? (
displayRequiredFields ? (
<div className={getClassName("kcContentWrapperClass")}>
<div className={clsx(getClassName("kcLabelWrapperClass"), "subtitle")}>
<div className={kcClsx("kcContentWrapperClass")}>
<div className={clsx(kcClsx("kcLabelWrapperClass"), "subtitle")}>
<span className="subtitle">
<span className="required">*</span>
{msg("requiredFields")}
@ -183,19 +181,19 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
<h1 id="kc-page-title">{headerNode}</h1>
)
) : displayRequiredFields ? (
<div className={getClassName("kcContentWrapperClass")}>
<div className={clsx(getClassName("kcLabelWrapperClass"), "subtitle")}>
<div className={kcClsx("kcContentWrapperClass")}>
<div className={clsx(kcClsx("kcLabelWrapperClass"), "subtitle")}>
<span className="subtitle">
<span className="required">*</span> {msg("requiredFields")}
</span>
</div>
<div className="col-md-10">
{showUsernameNode}
<div id="kc-username" className={getClassName("kcFormGroupClass")}>
<div id="kc-username" className={kcClsx("kcFormGroupClass")}>
<label id="kc-attempted-username">{auth.attemptedUsername}</label>
<a id="reset-login" href={url.loginRestartFlowUrl} aria-label={msgStr("restartLoginTooltip")}>
<div className="kc-login-tooltip">
<i className={getClassName("kcResetFlowIcon")}></i>
<i className={kcClsx("kcResetFlowIcon")}></i>
<span className="kc-tooltip-text">{msg("restartLoginTooltip")}</span>
</div>
</a>
@ -205,11 +203,11 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
) : (
<>
{showUsernameNode}
<div id="kc-username" className={getClassName("kcFormGroupClass")}>
<div id="kc-username" className={kcClsx("kcFormGroupClass")}>
<label id="kc-attempted-username">{auth.attemptedUsername}</label>
<a id="reset-login" href={url.loginRestartFlowUrl} aria-label={msgStr("restartLoginTooltip")}>
<div className="kc-login-tooltip">
<i className={getClassName("kcResetFlowIcon")}></i>
<i className={kcClsx("kcResetFlowIcon")}></i>
<span className="kc-tooltip-text">{msg("restartLoginTooltip")}</span>
</div>
</a>
@ -224,18 +222,18 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
<div
className={clsx(
`alert-${message.type}`,
getClassName("kcAlertClass"),
kcClsx("kcAlertClass"),
`pf-m-${message?.type === "error" ? "danger" : message.type}`
)}
>
<div className="pf-c-alert__icon">
{message.type === "success" && <span className={getClassName("kcFeedbackSuccessIcon")}></span>}
{message.type === "warning" && <span className={getClassName("kcFeedbackWarningIcon")}></span>}
{message.type === "error" && <span className={getClassName("kcFeedbackErrorIcon")}></span>}
{message.type === "info" && <span className={getClassName("kcFeedbackInfoIcon")}></span>}
{message.type === "success" && <span className={kcClsx("kcFeedbackSuccessIcon")}></span>}
{message.type === "warning" && <span className={kcClsx("kcFeedbackWarningIcon")}></span>}
{message.type === "error" && <span className={kcClsx("kcFeedbackErrorIcon")}></span>}
{message.type === "info" && <span className={kcClsx("kcFeedbackInfoIcon")}></span>}
</div>
<span
className={getClassName("kcAlertTitleClass")}
className={kcClsx("kcAlertTitleClass")}
dangerouslySetInnerHTML={{
__html: message.summary
}}
@ -245,10 +243,9 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
{children}
{auth !== undefined && auth.showTryAnotherWayLink && (
<form id="kc-select-try-another-way-form" action={url.loginAction} method="post">
<div className={getClassName("kcFormGroupClass")}>
<div className={getClassName("kcFormGroupClass")}>
<div className={kcClsx("kcFormGroupClass")}>
<div className={kcClsx("kcFormGroupClass")}>
<input type="hidden" name="tryAnotherWay" value="on" />
{/* eslint-disable-next-line jsx-a11y/anchor-is-valid */}
<a
href="#"
id="try-another-way"
@ -265,8 +262,8 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
)}
{socialProvidersNode}
{displayInfo && (
<div id="kc-info" className={getClassName("kcSignUpClass")}>
<div id="kc-info-wrapper" className={getClassName("kcInfoAreaWrapperClass")}>
<div id="kc-info" className={kcClsx("kcSignUpClass")}>
<div id="kc-info-wrapper" className={kcClsx("kcInfoAreaWrapperClass")}>
{infoNode}
</div>
</div>

View File

@ -1,15 +1,11 @@
import type { ReactNode } from "react";
import type { KcContext } from "./kcContext";
import type { I18n } from "./i18n";
export type TemplateProps<
KcContext extends KcContext.Common,
I18nExtended extends I18n
> = {
export type TemplateProps<KcContext, I18n> = {
kcContext: KcContext;
i18n: I18nExtended;
i18n: I18n;
doUseDefaultCss: boolean;
classes?: Partial<Record<ClassKey, string>>;
children: ReactNode;
displayInfo?: boolean;
displayMessage?: boolean;
@ -21,8 +17,6 @@ export type TemplateProps<
infoNode?: ReactNode;
documentTitle?: string;
bodyClassName?: string;
children: ReactNode;
};
export type ClassKey =

View File

@ -1,15 +1,22 @@
import { useEffect, useReducer, Fragment } from "react";
import type { ClassKey } from "keycloakify/login/TemplateProps";
import { useUserProfileForm, type KcContextLike, type FormAction, type FormFieldError } from "keycloakify/login/lib/useUserProfileForm";
import type { Attribute } from "keycloakify/login/kcContext/KcContext";
import { assert } from "tsafe/assert";
import type { KcClsx } from "keycloakify/login/lib/kcClsx";
import {
useUserProfileForm,
getButtonToDisplayForMultivaluedAttributeField,
type KcContextLike,
type FormAction,
type FormFieldError
} from "keycloakify/login/lib/useUserProfileForm";
import type { Attribute } from "keycloakify/login/KcContext";
import type { I18n } from "./i18n";
export type UserProfileFormFieldsProps = {
kcContext: KcContextLike;
i18n: I18n;
getClassName: (classKey: ClassKey) => string;
kcClsx: KcClsx;
onIsFormSubmittableValueChange: (isFormSubmittable: boolean) => void;
doMakeUserConfirmPassword: boolean;
BeforeField?: (props: BeforeAfterFieldProps) => JSX.Element | null;
AfterField?: (props: BeforeAfterFieldProps) => JSX.Element | null;
};
@ -18,15 +25,13 @@ type BeforeAfterFieldProps = {
attribute: Attribute;
dispatchFormAction: React.Dispatch<FormAction>;
displayableErrors: FormFieldError[];
i18n: I18n;
valueOrValues: string | string[];
kcClsx: KcClsx;
i18n: I18n;
};
// NOTE: Enabled by default but it's a UX best practice to set it to false.
const doMakeUserConfirmPassword = true;
export default function UserProfileFormFields(props: UserProfileFormFieldsProps) {
const { kcContext, onIsFormSubmittableValueChange, i18n, getClassName, BeforeField, AfterField } = props;
const { kcContext, i18n, kcClsx, onIsFormSubmittableValueChange, doMakeUserConfirmPassword, BeforeField, AfterField } = props;
const { advancedMsg } = i18n;
@ -50,32 +55,33 @@ export default function UserProfileFormFields(props: UserProfileFormFieldsProps)
{formFieldStates.map(({ attribute, displayableErrors, valueOrValues }) => {
return (
<Fragment key={attribute.name}>
<GroupLabel attribute={attribute} getClassName={getClassName} i18n={i18n} groupNameRef={groupNameRef} />
<GroupLabel attribute={attribute} groupNameRef={groupNameRef} i18n={i18n} kcClsx={kcClsx} />
{BeforeField !== undefined && (
<BeforeField
attribute={attribute}
dispatchFormAction={dispatchFormAction}
displayableErrors={displayableErrors}
i18n={i18n}
valueOrValues={valueOrValues}
kcClsx={kcClsx}
i18n={i18n}
/>
)}
<div
className={getClassName("kcFormGroupClass")}
className={kcClsx("kcFormGroupClass")}
style={{
display: attribute.name === "password-confirm" && !doMakeUserConfirmPassword ? "none" : undefined
}}
>
<div className={getClassName("kcLabelWrapperClass")}>
<label htmlFor={attribute.name} className={getClassName("kcLabelClass")}>
<div className={kcClsx("kcLabelWrapperClass")}>
<label htmlFor={attribute.name} className={kcClsx("kcLabelClass")}>
{advancedMsg(attribute.displayName ?? "")}
</label>
{attribute.required && <>*</>}
</div>
<div className={getClassName("kcInputWrapperClass")}>
<div className={kcClsx("kcInputWrapperClass")}>
{attribute.annotations.inputHelperTextBefore !== undefined && (
<div
className={getClassName("kcInputHelperTextBeforeClass")}
className={kcClsx("kcInputHelperTextBeforeClass")}
id={`form-help-text-before-${attribute.name}`}
aria-live="polite"
>
@ -86,19 +92,14 @@ export default function UserProfileFormFields(props: UserProfileFormFieldsProps)
attribute={attribute}
valueOrValues={valueOrValues}
displayableErrors={displayableErrors}
formValidationDispatch={dispatchFormAction}
getClassName={getClassName}
dispatchFormAction={dispatchFormAction}
kcClsx={kcClsx}
i18n={i18n}
/>
<FieldErrors
attribute={attribute}
getClassName={getClassName}
displayableErrors={displayableErrors}
fieldIndex={undefined}
/>
<FieldErrors attribute={attribute} displayableErrors={displayableErrors} kcClsx={kcClsx} fieldIndex={undefined} />
{attribute.annotations.inputHelperTextAfter !== undefined && (
<div
className={getClassName("kcInputHelperTextAfterClass")}
className={kcClsx("kcInputHelperTextAfterClass")}
id={`form-help-text-after-${attribute.name}`}
aria-live="polite"
>
@ -111,8 +112,9 @@ export default function UserProfileFormFields(props: UserProfileFormFieldsProps)
attribute={attribute}
dispatchFormAction={dispatchFormAction}
displayableErrors={displayableErrors}
i18n={i18n}
valueOrValues={valueOrValues}
kcClsx={kcClsx}
i18n={i18n}
/>
)}
{/* NOTE: Downloading of html5DataAnnotations scripts is done in the useUserProfileForm hook */}
@ -127,13 +129,13 @@ export default function UserProfileFormFields(props: UserProfileFormFieldsProps)
function GroupLabel(props: {
attribute: Attribute;
getClassName: UserProfileFormFieldsProps["getClassName"];
i18n: I18n;
groupNameRef: {
current: string;
};
i18n: I18n;
kcClsx: KcClsx;
}) {
const { attribute, getClassName, i18n, groupNameRef } = props;
const { attribute, groupNameRef, i18n, kcClsx } = props;
const { advancedMsg } = i18n;
@ -145,7 +147,7 @@ function GroupLabel(props: {
return (
<div
className={getClassName("kcFormGroupClass")}
className={kcClsx("kcFormGroupClass")}
{...Object.fromEntries(Object.entries(attribute.group.html5DataAnnotations).map(([key, value]) => [`data-${key}`, value]))}
>
{(() => {
@ -153,8 +155,8 @@ function GroupLabel(props: {
const groupHeaderText = groupDisplayHeader !== "" ? advancedMsg(groupDisplayHeader) : attribute.group.name;
return (
<div className={getClassName("kcContentWrapperClass")}>
<label id={`header-${attribute.group.name}`} className={getClassName("kcFormGroupHeader")}>
<div className={kcClsx("kcContentWrapperClass")}>
<label id={`header-${attribute.group.name}`} className={kcClsx("kcFormGroupHeader")}>
{groupHeaderText}
</label>
</div>
@ -167,8 +169,8 @@ function GroupLabel(props: {
const groupDescriptionText = advancedMsg(groupDisplayDescription);
return (
<div className={getClassName("kcLabelWrapperClass")}>
<label id={`description-${attribute.group.name}`} className={getClassName("kcLabelClass")}>
<div className={kcClsx("kcLabelWrapperClass")}>
<label id={`description-${attribute.group.name}`} className={kcClsx("kcLabelClass")}>
{groupDescriptionText}
</label>
</div>
@ -185,13 +187,8 @@ function GroupLabel(props: {
return null;
}
function FieldErrors(props: {
attribute: Attribute;
getClassName: UserProfileFormFieldsProps["getClassName"];
displayableErrors: FormFieldError[];
fieldIndex: number | undefined;
}) {
const { attribute, getClassName, fieldIndex } = props;
function FieldErrors(props: { attribute: Attribute; displayableErrors: FormFieldError[]; fieldIndex: number | undefined; kcClsx: KcClsx }) {
const { attribute, fieldIndex, kcClsx } = props;
const displayableErrors = props.displayableErrors.filter(error => error.fieldIndex === fieldIndex);
@ -202,7 +199,7 @@ function FieldErrors(props: {
return (
<span
id={`input-error-${attribute.name}${fieldIndex === undefined ? "" : `-${fieldIndex}`}`}
className={getClassName("kcInputErrorMessageClass")}
className={kcClsx("kcInputErrorMessageClass")}
aria-live="polite"
>
{displayableErrors
@ -221,9 +218,9 @@ type InputFiledByTypeProps = {
attribute: Attribute;
valueOrValues: string | string[];
displayableErrors: FormFieldError[];
formValidationDispatch: React.Dispatch<FormAction>;
getClassName: UserProfileFormFieldsProps["getClassName"];
dispatchFormAction: React.Dispatch<FormAction>;
i18n: I18n;
kcClsx: KcClsx;
};
function InputFiledByType(props: InputFiledByTypeProps) {
@ -253,7 +250,7 @@ function InputFiledByType(props: InputFiledByTypeProps) {
if (attribute.name === "password" || attribute.name === "password-confirm") {
return (
<PasswordWrapper getClassName={props.getClassName} i18n={props.i18n} passwordInputId={attribute.name}>
<PasswordWrapper kcClsx={props.kcClsx} i18n={props.i18n} passwordInputId={attribute.name}>
{inputNode}
</PasswordWrapper>
);
@ -264,8 +261,8 @@ function InputFiledByType(props: InputFiledByTypeProps) {
}
}
function PasswordWrapper(props: { getClassName: (classKey: ClassKey) => string; i18n: I18n; passwordInputId: string; children: JSX.Element }) {
const { getClassName, i18n, passwordInputId, children } = props;
function PasswordWrapper(props: { kcClsx: KcClsx; i18n: I18n; passwordInputId: string; children: JSX.Element }) {
const { kcClsx, i18n, passwordInputId, children } = props;
const { msgStr } = i18n;
@ -280,26 +277,23 @@ function PasswordWrapper(props: { getClassName: (classKey: ClassKey) => string;
}, [isPasswordRevealed]);
return (
<div className={getClassName("kcInputGroup")}>
<div className={kcClsx("kcInputGroup")}>
{children}
<button
type="button"
className={getClassName("kcFormPasswordVisibilityButtonClass")}
className={kcClsx("kcFormPasswordVisibilityButtonClass")}
aria-label={msgStr(isPasswordRevealed ? "hidePassword" : "showPassword")}
aria-controls={passwordInputId}
onClick={toggleIsPasswordRevealed}
>
<i
className={getClassName(isPasswordRevealed ? "kcFormPasswordVisibilityIconHide" : "kcFormPasswordVisibilityIconShow")}
aria-hidden
/>
<i className={kcClsx(isPasswordRevealed ? "kcFormPasswordVisibilityIconHide" : "kcFormPasswordVisibilityIconShow")} aria-hidden />
</button>
</div>
);
}
function InputTag(props: InputFiledByTypeProps & { fieldIndex: number | undefined }) {
const { attribute, fieldIndex, getClassName, formValidationDispatch, valueOrValues, i18n, displayableErrors } = props;
const { attribute, fieldIndex, kcClsx, dispatchFormAction, valueOrValues, i18n, displayableErrors } = props;
return (
<>
@ -325,7 +319,7 @@ function InputTag(props: InputFiledByTypeProps & { fieldIndex: number | undefine
return valueOrValues;
})()}
className={getClassName("kcInputClass")}
className={kcClsx("kcInputClass")}
aria-invalid={displayableErrors.find(error => error.fieldIndex === fieldIndex) !== undefined}
disabled={attribute.readOnly}
autoComplete={attribute.autocomplete}
@ -343,7 +337,7 @@ function InputTag(props: InputFiledByTypeProps & { fieldIndex: number | undefine
step={attribute.annotations.inputTypeStep}
{...Object.fromEntries(Object.entries(attribute.html5DataAnnotations ?? {}).map(([key, value]) => [`data-${key}`, value]))}
onChange={event =>
formValidationDispatch({
dispatchFormAction({
action: "update",
name: attribute.name,
valueOrValues: (() => {
@ -364,7 +358,7 @@ function InputTag(props: InputFiledByTypeProps & { fieldIndex: number | undefine
})
}
onBlur={() =>
props.formValidationDispatch({
dispatchFormAction({
action: "focus lost",
name: attribute.name,
fieldIndex: fieldIndex
@ -382,17 +376,12 @@ function InputTag(props: InputFiledByTypeProps & { fieldIndex: number | undefine
return (
<>
<FieldErrors
attribute={attribute}
getClassName={getClassName}
displayableErrors={displayableErrors}
fieldIndex={fieldIndex}
/>
<FieldErrors attribute={attribute} kcClsx={kcClsx} displayableErrors={displayableErrors} fieldIndex={fieldIndex} />
<AddRemoveButtonsMultiValuedAttribute
attribute={attribute}
values={values}
fieldIndex={fieldIndex}
dispatchFormAction={formValidationDispatch}
dispatchFormAction={dispatchFormAction}
i18n={i18n}
/>
</>
@ -413,92 +402,34 @@ function AddRemoveButtonsMultiValuedAttribute(props: {
const { msg } = i18n;
const hasRemove = (() => {
if (values.length === 1) {
return false;
}
const { hasAdd, hasRemove } = getButtonToDisplayForMultivaluedAttributeField({ attribute, values, fieldIndex });
const minCount = (() => {
const { multivalued } = attribute.validators;
if (multivalued === undefined) {
return undefined;
}
const minStr = multivalued.min;
if (minStr === undefined) {
return undefined;
}
return parseInt(`${minStr}`);
})();
if (minCount === undefined) {
return true;
}
if (values.length === minCount) {
return false;
}
return true;
})();
const hasAdd = (() => {
if (fieldIndex + 1 !== values.length) {
return false;
}
const maxCount = (() => {
const { multivalued } = attribute.validators;
if (multivalued === undefined) {
return undefined;
}
const maxStr = multivalued.max;
if (maxStr === undefined) {
return undefined;
}
return parseInt(`${maxStr}`);
})();
if (maxCount === undefined) {
return false;
}
if (values.length === maxCount) {
return false;
}
return true;
})();
const idPostfix = `-${attribute.name}-${fieldIndex + 1}`;
return (
<>
{hasRemove && (
<button
id={`kc-remove-${attribute.name}-${fieldIndex + 1}`}
type="button"
className="pf-c-button pf-m-inline pf-m-link"
onClick={() =>
dispatchFormAction({
action: "update",
name: attribute.name,
valueOrValues: values.filter((_, i) => i !== fieldIndex)
})
}
>
{msg("remove")}
{hasRemove ? <>&nbsp;|&nbsp;</> : null}
</button>
<>
<button
id={`kc-remove${idPostfix}`}
type="button"
className="pf-c-button pf-m-inline pf-m-link"
onClick={() =>
dispatchFormAction({
action: "update",
name: attribute.name,
valueOrValues: values.filter((_, i) => i !== fieldIndex)
})
}
>
{msg("remove")}
</button>
{hasAdd ? <>&nbsp;|&nbsp;</> : null}
</>
)}
{hasAdd && (
<button
id="kc-add-titles-1"
id={`kc-add${idPostfix}`}
type="button"
className="pf-c-button pf-m-inline pf-m-link"
onClick={() =>
@ -517,7 +448,7 @@ function AddRemoveButtonsMultiValuedAttribute(props: {
}
function InputTagSelects(props: InputFiledByTypeProps) {
const { attribute, formValidationDispatch, getClassName, valueOrValues } = props;
const { attribute, dispatchFormAction, kcClsx, valueOrValues } = props;
const { advancedMsg } = props.i18n;
@ -530,16 +461,16 @@ function InputTagSelects(props: InputFiledByTypeProps) {
case "select-radiobuttons":
return {
inputType: "radio",
classDiv: getClassName("kcInputClassRadio"),
classInput: getClassName("kcInputClassRadioInput"),
classLabel: getClassName("kcInputClassRadioLabel")
classDiv: kcClsx("kcInputClassRadio"),
classInput: kcClsx("kcInputClassRadioInput"),
classLabel: kcClsx("kcInputClassRadioLabel")
};
case "multiselect-checkboxes":
return {
inputType: "checkbox",
classDiv: getClassName("kcInputClassCheckbox"),
classInput: getClassName("kcInputClassCheckboxInput"),
classLabel: getClassName("kcInputClassCheckboxLabel")
classDiv: kcClsx("kcInputClassCheckbox"),
classInput: kcClsx("kcInputClassCheckboxInput"),
classLabel: kcClsx("kcInputClassCheckboxLabel")
};
}
})();
@ -580,9 +511,9 @@ function InputTagSelects(props: InputFiledByTypeProps) {
className={classInput}
aria-invalid={props.displayableErrors.length !== 0}
disabled={attribute.readOnly}
checked={valueOrValues.includes(option)}
checked={valueOrValues instanceof Array ? valueOrValues.includes(option) : valueOrValues === option}
onChange={event =>
formValidationDispatch({
dispatchFormAction({
action: "update",
name: attribute.name,
valueOrValues: (() => {
@ -605,7 +536,7 @@ function InputTagSelects(props: InputFiledByTypeProps) {
})
}
onBlur={() =>
formValidationDispatch({
dispatchFormAction({
action: "focus lost",
name: attribute.name,
fieldIndex: undefined
@ -614,7 +545,7 @@ function InputTagSelects(props: InputFiledByTypeProps) {
/>
<label
htmlFor={`${attribute.name}-${option}`}
className={`${classLabel}${attribute.readOnly ? ` ${getClassName("kcInputClassRadioCheckboxLabelDisabled")}` : ""}`}
className={`${classLabel}${attribute.readOnly ? ` ${kcClsx("kcInputClassRadioCheckboxLabelDisabled")}` : ""}`}
>
{advancedMsg(option)}
</label>
@ -625,7 +556,7 @@ function InputTagSelects(props: InputFiledByTypeProps) {
}
function TextareaTag(props: InputFiledByTypeProps) {
const { attribute, formValidationDispatch, getClassName, displayableErrors, valueOrValues } = props;
const { attribute, dispatchFormAction, kcClsx, displayableErrors, valueOrValues } = props;
assert(typeof valueOrValues === "string");
@ -635,7 +566,7 @@ function TextareaTag(props: InputFiledByTypeProps) {
<textarea
id={attribute.name}
name={attribute.name}
className={getClassName("kcInputClass")}
className={kcClsx("kcInputClass")}
aria-invalid={displayableErrors.length !== 0}
disabled={attribute.readOnly}
cols={attribute.annotations.inputTypeCols === undefined ? undefined : parseInt(`${attribute.annotations.inputTypeCols}`)}
@ -643,14 +574,14 @@ function TextareaTag(props: InputFiledByTypeProps) {
maxLength={attribute.annotations.inputTypeMaxlength === undefined ? undefined : parseInt(`${attribute.annotations.inputTypeMaxlength}`)}
value={value}
onChange={event =>
formValidationDispatch({
dispatchFormAction({
action: "update",
name: attribute.name,
valueOrValues: event.target.value
})
}
onBlur={() =>
formValidationDispatch({
dispatchFormAction({
action: "focus lost",
name: attribute.name,
fieldIndex: undefined
@ -661,7 +592,7 @@ function TextareaTag(props: InputFiledByTypeProps) {
}
function SelectTag(props: InputFiledByTypeProps) {
const { attribute, formValidationDispatch, getClassName, displayableErrors, i18n, valueOrValues } = props;
const { attribute, dispatchFormAction, kcClsx, displayableErrors, i18n, valueOrValues } = props;
const { advancedMsg } = i18n;
@ -671,14 +602,14 @@ function SelectTag(props: InputFiledByTypeProps) {
<select
id={attribute.name}
name={attribute.name}
className={getClassName("kcInputClass")}
className={kcClsx("kcInputClass")}
aria-invalid={displayableErrors.length !== 0}
disabled={attribute.readOnly}
multiple={isMultiple}
size={attribute.annotations.inputTypeSize === undefined ? undefined : parseInt(`${attribute.annotations.inputTypeSize}`)}
value={valueOrValues}
onChange={event =>
formValidationDispatch({
dispatchFormAction({
action: "update",
name: attribute.name,
valueOrValues: (() => {
@ -691,7 +622,7 @@ function SelectTag(props: InputFiledByTypeProps) {
})
}
onBlur={() =>
formValidationDispatch({
dispatchFormAction({
action: "focus lost",
name: attribute.name,
fieldIndex: undefined

View File

@ -1,9 +1,10 @@
import "minimal-polyfills/Object.fromEntries";
import { useEffect, useState, useRef } from "react";
import fallbackMessages from "./baseMessages/en";
import { getMessages } from "./baseMessages";
import "keycloakify/tools/Object.fromEntries";
import { useEffect, useState } from "react";
import { assert } from "tsafe/assert";
import type { KcContext } from "../kcContext/KcContext";
import messages_fallbackLanguage from "./baseMessages/en";
import { getMessages } from "./baseMessages";
import type { KcContext } from "../KcContext";
import { Reflect } from "tsafe/Reflect";
export const fallbackLanguageTag = "en";
@ -12,12 +13,12 @@ export type KcContextLike = {
currentLanguageTag: string;
supported: { languageTag: string; url: string; label: string }[];
};
__localizationRealmOverridesUserProfile: Record<string, string>;
__localizationRealmOverridesUserProfile?: Record<string, string>;
};
assert<KcContext extends KcContextLike ? true : false>();
export type MessageKey = keyof typeof fallbackMessages | keyof (typeof keycloakifyExtraMessages)[typeof fallbackLanguageTag];
export type MessageKey = keyof typeof messages_fallbackLanguage;
export type GenericI18n<MessageKey extends string> = {
/**
@ -52,207 +53,289 @@ export type GenericI18n<MessageKey extends string> = {
*/
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"
* advancedMsg("${access-denied} foo bar") === <span>${msgStr("access-denied")} foo bar<span> === <span>Access denied foo bar</span>
* {
* en: {
* "access-denied": "Access denied",
* "foo": "Foo {0} {1}",
* "bar": "Bar {0}"
* }
* }
*
* advancedMsg("${access-denied} foo bar") === <span>{msgStr("access-denied")} foo bar<span> === <span>Access denied foo bar</span>
* advancedMsg("${access-denied}") === advancedMsg("access-denied") === msg("access-denied") === <span>Access denied</span>
* advancedMsg("${not-a-message-key}") === advancedMsg(not-a-message-key") === <span>not-a-message-key</span>
* advancedMsg("${bar}", "<strong>c</strong>")
* === <span>{msgStr("bar", "<strong>XXX</strong>")}<span>
* === <span>Bar &lt;strong&gt;XXX&lt;/strong&gt;</span> (The html in the arg is partially escaped for security reasons, it might be untrusted)
* advancedMsg("${foo} xx ${bar}", "a", "b", "c")
* === <span>{msgStr("foo", "a", "b")} xx {msgStr("bar")}<span>
* === <span>Foo a b xx Bar {0}</span> (The substitution are only applied in the first message)
*/
advancedMsg: (key: string, ...args: (string | undefined)[]) => JSX.Element;
/**
* Examples assuming currentLanguageTag === "en"
* advancedMsg("${access-denied} foo bar") === msg("access-denied") + " foo bar" === "Access denied foo bar"
* advancedMsg("${not-a-message-key}") === advancedMsg("not-a-message-key") === "not-a-message-key"
* See advancedMsg() but instead of returning a JSX.Element it returns a string.
*/
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 I18n = GenericI18n<MessageKey>;
function createGetI18n<ExtraMessageKey extends string = never>(extraMessages: { [languageTag: string]: { [key in ExtraMessageKey]: string } }) {
type I18n = GenericI18n<MessageKey | ExtraMessageKey>;
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" | "getChangeLocalUrl" | "labelBySupportedLanguageTag"> = {
currentLanguageTag: kcContext.locale?.currentLanguageTag ?? fallbackLanguageTag,
getChangeLocalUrl: 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, ExtraMessageKey>({
messages_fallbackLanguage,
extraMessages_fallbackLanguage: extraMessages[fallbackLanguageTag],
extraMessages: extraMessages[partialI18n.currentLanguageTag],
__localizationRealmOverridesUserProfile: kcContext.__localizationRealmOverridesUserProfile
});
const isCurrentLanguageFallbackLanguage = partialI18n.currentLanguageTag === fallbackLanguageTag;
const result: Result = {
i18n: {
...partialI18n,
...createI18nTranslationFunctions({ messages: undefined }),
isFetchingTranslations: !isCurrentLanguageFallbackLanguage
},
prI18n_currentLanguage: isCurrentLanguageFallbackLanguage
? undefined
: (async () => {
const messages = await getMessages(partialI18n.currentLanguageTag);
const i18n_currentLanguage: I18n = {
...partialI18n,
...createI18nTranslationFunctions({ messages }),
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 };
}
export function createUseI18n<ExtraMessageKey extends string = never>(extraMessages: {
[languageTag: string]: { [key in ExtraMessageKey]: string };
}) {
function useI18n(params: { kcContext: KcContextLike }): GenericI18n<MessageKey | ExtraMessageKey> | null {
type I18n = GenericI18n<MessageKey | ExtraMessageKey>;
const { getI18n } = createGetI18n(extraMessages);
function useI18n(params: { kcContext: KcContextLike }): { i18n: I18n } {
const { kcContext } = params;
const [i18n, setI18n] = useState<GenericI18n<ExtraMessageKey | MessageKey> | undefined>(undefined);
const { i18n, prI18n_currentLanguage } = getI18n({ kcContext });
const refHasStartedFetching = useRef(false);
const [i18n_toReturn, setI18n_toReturn] = useState<I18n>(i18n);
useEffect(() => {
if (refHasStartedFetching.current) {
return;
}
let isActive = true;
refHasStartedFetching.current = true;
(async () => {
const { currentLanguageTag = fallbackLanguageTag } = kcContext.locale ?? {};
setI18n({
...createI18nTranslationFunctions({
fallbackMessages: {
...fallbackMessages,
...(keycloakifyExtraMessages[fallbackLanguageTag] ?? {}),
...(extraMessages[fallbackLanguageTag] ?? {})
} as any,
messages: {
...(await getMessages(currentLanguageTag)),
...((keycloakifyExtraMessages as any)[currentLanguageTag] ?? {}),
...(extraMessages[currentLanguageTag] ?? {})
} as any,
__localizationRealmOverridesUserProfile: kcContext.__localizationRealmOverridesUserProfile
}),
currentLanguageTag,
getChangeLocalUrl: 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])
)
});
})();
}, []);
return i18n ?? null;
}
return { useI18n };
}
function createI18nTranslationFunctions<MessageKey extends string>(params: {
fallbackMessages: Record<MessageKey, string>;
messages: Record<MessageKey, string>;
__localizationRealmOverridesUserProfile: Record<string, string>;
}): Pick<GenericI18n<MessageKey>, "msg" | "msgStr" | "advancedMsg" | "advancedMsgStr"> {
const { fallbackMessages, messages /*__localizationRealmOverridesUserProfile*/ } = params;
function resolveMsg(props: { key: string; args: (string | undefined)[]; doRenderAsHtml: boolean }): string | JSX.Element | undefined {
const { key, args, doRenderAsHtml } = props;
const messageOrUndefined: string | undefined = (messages as any)[key] ?? (fallbackMessages as any)[key];
if (messageOrUndefined === undefined) {
return undefined;
}
const message = messageOrUndefined;
const messageWithArgsInjectedIfAny = (() => {
const startIndex = message
.match(/{[0-9]+}/g)
?.map(g => g.match(/{([0-9]+)}/)![1])
.map(indexStr => parseInt(indexStr))
.sort((a, b) => a - b)[0];
if (startIndex === undefined) {
// No {0} in message (no arguments expected)
return message;
}
let messageWithArgsInjected = message;
args.forEach((arg, i) => {
if (arg === undefined) {
prI18n_currentLanguage?.then(i18n => {
if (!isActive) {
return;
}
messageWithArgsInjected = messageWithArgsInjected.replace(
new RegExp(`\\{${i + startIndex}\\}`, "g"),
arg.replace(/</g, "&lt;").replace(/>/g, "&gt;")
);
setI18n_toReturn(i18n);
});
return messageWithArgsInjected;
})();
return () => {
isActive = false;
};
}, []);
return doRenderAsHtml ? (
<span
// NOTE: The message is trusted. The arguments are not but are escaped.
dangerouslySetInnerHTML={{
__html: messageWithArgsInjectedIfAny
}}
/>
) : (
messageWithArgsInjectedIfAny
);
return { i18n: i18n_toReturn };
}
function resolveMsgAdvanced(props: { key: string; args: (string | undefined)[]; doRenderAsHtml: boolean }): JSX.Element | string {
const { key, args, doRenderAsHtml } = props;
// TODO:
/*
if( key in __localizationRealmOverridesUserProfile ){
}
*/
const match = key.match(/^\$\{([^{]+)\}$/);
const keyUnwrappedFromCurlyBraces = match === null ? key : match[1];
const out = resolveMsg({
key: keyUnwrappedFromCurlyBraces,
args,
doRenderAsHtml
});
return (out !== undefined ? out : doRenderAsHtml ? <span>{keyUnwrappedFromCurlyBraces}</span> : keyUnwrappedFromCurlyBraces) as any;
}
return {
msgStr: (key, ...args) => resolveMsg({ key, args, doRenderAsHtml: false }) as string,
msg: (key, ...args) => resolveMsg({ key, args, doRenderAsHtml: true }) as JSX.Element,
advancedMsg: (key, ...args) =>
resolveMsgAdvanced({
key,
args,
doRenderAsHtml: true
}) as JSX.Element,
advancedMsgStr: (key, ...args) =>
resolveMsgAdvanced({
key,
args,
doRenderAsHtml: false
}) as string
};
return { useI18n, ofTypeI18n: Reflect<I18n>() };
}
const keycloakifyExtraMessages = {
en: {
shouldBeEqual: "{0} should be equal to {1}",
shouldBeDifferent: "{0} should be different to {1}",
shouldMatchPattern: "Pattern should match: `/{0}/`",
mustBeAnInteger: "Must be an integer",
notAValidOption: "Not a valid option",
selectAnOption: "Select an option",
remove: "Remove",
addValue: "Add value"
},
fr: {
/* spell-checker: disable */
shouldBeEqual: "{0} doit être égal à {1}",
shouldBeDifferent: "{0} doit être différent de {1}",
shouldMatchPattern: "Dois respecter le schéma: `/{0}/`",
mustBeAnInteger: "Doit être un nombre entier",
notAValidOption: "N'est pas une option valide",
function createI18nTranslationFunctionsFactory<MessageKey extends string, ExtraMessageKey extends string>(params: {
messages_fallbackLanguage: Record<MessageKey, string>;
extraMessages_fallbackLanguage: Record<ExtraMessageKey, string> | undefined;
extraMessages: Partial<Record<ExtraMessageKey, string>> | undefined;
__localizationRealmOverridesUserProfile: Record<string, string> | undefined;
}) {
const { __localizationRealmOverridesUserProfile, extraMessages } = params;
logoutConfirmTitle: "Déconnexion",
logoutConfirmHeader: "Êtes-vous sûr(e) de vouloir vous déconnecter ?",
doLogout: "Se déconnecter",
selectAnOption: "Sélectionner une option",
remove: "Supprimer",
addValue: "Ajouter une valeur"
/* spell-checker: enable */
const messages_fallbackLanguage = {
...params.messages_fallbackLanguage,
...params.extraMessages_fallbackLanguage
};
function createI18nTranslationFunctions(params: {
messages: Partial<Record<MessageKey, string>> | undefined;
}): Pick<GenericI18n<MessageKey | ExtraMessageKey>, "msg" | "msgStr" | "advancedMsg" | "advancedMsgStr"> {
const messages = {
...params.messages,
...extraMessages
};
function resolveMsg(props: { key: string; args: (string | undefined)[]; doRenderAsHtml: boolean }): string | JSX.Element | undefined {
const { key, args, doRenderAsHtml } = props;
const messageOrUndefined: string | undefined = (messages as any)[key] ?? (messages_fallbackLanguage as any)[key];
if (messageOrUndefined === undefined) {
return undefined;
}
const message = messageOrUndefined;
const messageWithArgsInjectedIfAny = (() => {
const startIndex = message
.match(/{[0-9]+}/g)
?.map(g => g.match(/{([0-9]+)}/)![1])
.map(indexStr => parseInt(indexStr))
.sort((a, b) => a - b)[0];
if (startIndex === undefined) {
// No {0} in message (no arguments expected)
return message;
}
let messageWithArgsInjected = message;
args.forEach((arg, i) => {
if (arg === undefined) {
return;
}
messageWithArgsInjected = messageWithArgsInjected.replace(
new RegExp(`\\{${i + startIndex}\\}`, "g"),
arg.replace(/</g, "&lt;").replace(/>/g, "&gt;")
);
});
return messageWithArgsInjected;
})();
return doRenderAsHtml ? (
<span
// NOTE: The message is trusted. The arguments are not but are escaped.
dangerouslySetInnerHTML={{
__html: messageWithArgsInjectedIfAny
}}
/>
) : (
messageWithArgsInjectedIfAny
);
}
function resolveMsgAdvanced(props: { key: string; args: (string | undefined)[]; doRenderAsHtml: boolean }): JSX.Element | string {
const { key, args, doRenderAsHtml } = props;
if (__localizationRealmOverridesUserProfile !== undefined && key in __localizationRealmOverridesUserProfile) {
const resolvedMessage = __localizationRealmOverridesUserProfile[key];
return doRenderAsHtml ? (
<span
// NOTE: The message is trusted. The arguments are not but are escaped.
dangerouslySetInnerHTML={{
__html: resolvedMessage
}}
/>
) : (
resolvedMessage
);
}
if (!/\$\{[^}]+\}/.test(key)) {
const resolvedMessage = resolveMsg({ key, args, doRenderAsHtml });
if (resolvedMessage === undefined) {
return doRenderAsHtml ? <span dangerouslySetInnerHTML={{ __html: key }} /> : key;
}
return resolvedMessage;
}
let isFirstMatch = true;
const resolvedComplexMessage = key.replace(/\$\{([^}]+)\}/g, (...[, key_i]) => {
const replaceBy = resolveMsg({ key: key_i, args: isFirstMatch ? args : [], doRenderAsHtml: false }) ?? key_i;
isFirstMatch = false;
return replaceBy;
});
return doRenderAsHtml ? <span dangerouslySetInnerHTML={{ __html: resolvedComplexMessage }} /> : resolvedComplexMessage;
}
return {
msgStr: (key, ...args) => resolveMsg({ key, args, doRenderAsHtml: false }) as string,
msg: (key, ...args) => resolveMsg({ key, args, doRenderAsHtml: true }) as JSX.Element,
advancedMsg: (key, ...args) =>
resolveMsgAdvanced({
key,
args,
doRenderAsHtml: true
}) as JSX.Element,
advancedMsgStr: (key, ...args) =>
resolveMsgAdvanced({
key,
args,
doRenderAsHtml: false
}) as string
};
}
};
return { createI18nTranslationFunctions };
}

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