diff --git a/.github/release.yaml b/.github/release.yaml deleted file mode 100644 index ae38baa8..00000000 --- a/.github/release.yaml +++ /dev/null @@ -1,25 +0,0 @@ -changelog: - exclude: - labels: - - ignore-for-release - authors: - - octocat - categories: - - title: Breaking Changes 🛠 - labels: - - breaking - - title: Exciting New Features 🎉 - labels: - - feature - - title: Fixes 🔧 - labels: - - fix - - title: Documentation 🔧 - labels: - - docs - - title: CI 👷 - labels: - - ci - - title: Other Changes - labels: - - '*' \ No newline at end of file diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 2c63dd9e..ea05d1d0 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -13,11 +13,11 @@ jobs: runs-on: ubuntu-latest if: ${{ !github.event.created && github.repository != 'garronej/ts-ci' }} steps: - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 - uses: bahmutov/npm-install@v1 - - name: If this step fails run 'yarn format' then commit again. - run: yarn format:check + - name: If this step fails run 'npm run format' then commit again. + run: npm run _format --list-different test: runs-on: ${{ matrix.os }} needs: test_lint @@ -27,30 +27,29 @@ jobs: os: [ ubuntu-latest ] name: Test with Node v${{ matrix.node }} on ${{ matrix.os }} steps: - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 with: node-version: ${{ matrix.node }} - uses: bahmutov/npm-install@v1 - - run: yarn build - - run: yarn test - #- run: yarn test:keycloakify-starter + - run: npm run build + - run: npm run test storybook: runs-on: ubuntu-latest - if: github.event_name == 'push' + #if: github.event_name == 'push' needs: test steps: - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 with: node-version: '18' - uses: bahmutov/npm-install@v1 - - run: yarn build-storybook -o ./build_storybook + - run: npm run build-storybook - run: git remote set-url origin https://git:${GITHUB_TOKEN}@github.com/${{github.repository}}.git env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - run: npx -y -p gh-pages@3.1.0 gh-pages -d ./build_storybook -u "github-actions-bot " + - run: npx -y -p gh-pages@3.1.0 gh-pages -d ./storybook-static -u "github-actions-bot " check_if_version_upgraded: name: Check if version upgrade @@ -70,7 +69,7 @@ jobs: is_upgraded_version: ${{ steps.step1.outputs.is_upgraded_version }} is_pre_release: ${{steps.step1.outputs.is_pre_release }} steps: - - uses: garronej/ts-ci@v2.1.0 + - uses: garronej/ts-ci@v2.1.2 id: step1 with: action_name: is_package_json_version_upgraded @@ -88,7 +87,7 @@ jobs: needs: - check_if_version_upgraded steps: - - uses: softprops/action-gh-release@v1 + - uses: softprops/action-gh-release@v2 with: name: Release v${{ needs.check_if_version_upgraded.outputs.to_version }} tag_name: v${{ needs.check_if_version_upgraded.outputs.to_version }} @@ -105,18 +104,18 @@ jobs: - create_github_release - check_if_version_upgraded steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: ref: ${{ github.ref }} - - uses: actions/setup-node@v3 + - uses: actions/setup-node@v4 with: registry-url: https://registry.npmjs.org/ - uses: bahmutov/npm-install@v1 - - run: yarn build - - run: npx -y -p denoify@1.3.0 enable_short_npm_import_path + - run: npm run build + - run: npx -y -p denoify@1.6.12 enable_short_npm_import_path env: DRY_RUN: "0" - - uses: garronej/ts-ci@v2.1.0 + - uses: garronej/ts-ci@v2.1.2 with: action_name: remove_dark_mode_specific_images_from_readme - name: Publishing on NPM diff --git a/.gitignore b/.gitignore index 5fd2cc8f..687df547 100644 --- a/.gitignore +++ b/.gitignore @@ -48,8 +48,8 @@ jspm_packages .idea -/src/login/i18n/baseMessages/ -/src/account/i18n/baseMessages/ +/src/login/i18n/messages_defaultSet/ +/src/account/i18n/messages_defaultSet/ # VS Code devcontainers .devcontainer diff --git a/.prettierignore b/.prettierignore index fadee0cc..6cc2f1d3 100644 --- a/.prettierignore +++ b/.prettierignore @@ -6,8 +6,8 @@ node_modules/ /src/tools/types/ /build_keycloak/ /.vscode/ -/src/login/i18n/baseMessages/ -/src/account/i18n/baseMessages/ +/src/login/i18n/messages_defaultSet/ +/src/account/i18n/messages_defaultSet/ /dist_test /sample_react_project/ /sample_custom_react_project/ diff --git a/.prettierrc.json b/.prettierrc.json index 1531ce9e..9a629901 100644 --- a/.prettierrc.json +++ b/.prettierrc.json @@ -1,11 +1,24 @@ { - "printWidth": 150, + "printWidth": 90, "tabWidth": 4, "useTabs": false, "semi": true, "singleQuote": false, - "quoteProps": "preserve", "trailingComma": "none", "bracketSpacing": true, - "arrowParens": "avoid" + "arrowParens": "avoid", + "overrides": [ + { + "files": "*.tsx", + "options": { + "printWidth": 150 + } + }, + { + "files": "useUserProfileForm.tsx", + "options": { + "printWidth": 150 + } + } + ] } diff --git a/.storybook/Containers.js b/.storybook/Containers.js index ff272f73..09082b3a 100644 --- a/.storybook/Containers.js +++ b/.storybook/Containers.js @@ -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; } - `} - {children} ); diff --git a/.storybook/preview-head.html b/.storybook/preview-head.html new file mode 100644 index 00000000..443a62d9 --- /dev/null +++ b/.storybook/preview-head.html @@ -0,0 +1,17 @@ + \ No newline at end of file diff --git a/.storybook/preview.js b/.storybook/preview.js index 5c33aa1d..e44f4eaf 100644 --- a/.storybook/preview.js +++ b/.storybook/preview.js @@ -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) { diff --git a/.storybook/static/terms/en.md b/.storybook/static/terms/en.md new file mode 100644 index 00000000..1027b85c --- /dev/null +++ b/.storybook/static/terms/en.md @@ -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]**. diff --git a/.storybook/static/terms/es.md b/.storybook/static/terms/es.md new file mode 100644 index 00000000..588342a5 --- /dev/null +++ b/.storybook/static/terms/es.md @@ -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]**. diff --git a/.storybook/static/terms/fr.md b/.storybook/static/terms/fr.md new file mode 100644 index 00000000..fe52ecc8 --- /dev/null +++ b/.storybook/static/terms/fr.md @@ -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]**. diff --git a/package.json b/package.json index ef3e11af..c88da84b 100644 --- a/package.json +++ b/package.json @@ -1,39 +1,26 @@ { "name": "keycloakify", - "version": "9.7.2", + "version": "10.0.0-rc.121", "description": "Create Keycloak themes using React", "repository": { "type": "git", "url": "git://github.com/keycloakify/keycloakify.git" }, - "main": "dist/index.js", - "types": "dist/index.d.ts", "scripts": { - "prepare": "yarn generate-i18n-messages", - "build": "rimraf dist/ && tsc -p src/bin && tsc -p src && tsc -p src/vite-plugin && tsc-alias -p src/tsconfig.json && yarn grant-exec-perms && yarn copy-files dist/ && cp -r src dist/", - "generate:json-schema": "ts-node scripts/generate-json-schema.ts", - "grant-exec-perms": "node dist/bin/tools/grant-exec-perms.js", - "copy-files": "copyfiles -u 1 src/**/*.ftl src/**/*.java", + "prepare": "tsx scripts/generate-i18n-messages.ts", + "build": "tsx scripts/build.ts", + "storybook": "tsx scripts/start-storybook.ts", + "link-in-starter": "tsx scripts/link-in-starter.ts", "test": "yarn test:types && vitest run", - "test:keycloakify-starter": "ts-node scripts/test-keycloakify-starter", "test:types": "tsc -p test/tsconfig.json --noEmit", "_format": "prettier '**/*.{ts,tsx,json,md}'", "format": "yarn _format --write", - "format:check": "yarn _format --list-different", - "generate-i18n-messages": "ts-node --skipProject scripts/generate-i18n-messages.ts", - "link-in-app": "ts-node --skipProject scripts/link-in-app.ts", - "link-in-starter": "yarn link-in-app keycloakify-starter", - "watch-in-starter": "yarn build && yarn link-in-starter && (concurrently \"tsc -p src -w\" \"tsc-alias -p src/tsconfig.json\" \"tsc -p src/bin -w\")", - "copy-keycloak-resources-to-storybook-static": "PUBLIC_DIR_PATH=.storybook/static node dist/bin/copy-keycloak-resources-to-public.js", - "storybook": "yarn build && yarn copy-keycloak-resources-to-storybook-static && start-storybook -p 6006", - "build-storybook": "yarn build && yarn copy-keycloak-resources-to-storybook-static && build-storybook" + "link-in-app": "tsx scripts/link-in-app.ts", + "build-storybook": "tsx scripts/build-storybook.ts", + "dump-keycloak-realm": "tsx scripts/dump-keycloak-realm.ts" }, "bin": { - "copy-keycloak-resources-to-public": "dist/bin/copy-keycloak-resources-to-public.js", - "download-builtin-keycloak-theme": "dist/bin/download-builtin-keycloak-theme.js", - "eject-keycloak-page": "dist/bin/eject-keycloak-page.js", - "initialize-email-theme": "dist/bin/initialize-email-theme.js", - "keycloakify": "dist/bin/keycloakify/index.js" + "keycloakify": "dist/bin/main.js" }, "lint-staged": { "*.{ts,tsx,json,md}": [ @@ -48,28 +35,42 @@ "author": "u/garronej", "license": "MIT", "files": [ - "src/", "dist/", "!dist/tsconfig.tsbuildinfo", - "!dist/bin/tsconfig.tsbuildinfo" + "!dist/bin/", + "dist/bin/main.js", + "dist/bin/*.index.js", + "dist/bin/*.node", + "dist/bin/shared/constants.js", + "dist/bin/shared/*.d.ts", + "dist/bin/shared/*.js.map", + "!dist/vite-plugin/", + "dist/vite-plugin/index.js", + "dist/vite-plugin/index.d.ts", + "dist/vite-plugin/vite-plugin.d.ts" ], "keywords": [ - "bluehats", "keycloak", "react", "theme", "FreeMarker", "ftl", "login", - "register" + "register", + "account", + "bluehats" ], "homepage": "https://www.keycloakify.dev", - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + "dependencies": { + "tsafe": "^1.6.6" }, "devDependencies": { - "@babel/core": "^7.0.0", - "@emotion/react": "^11.10.6", + "@babel/core": "^7.24.5", + "@babel/generator": "^7.24.5", + "@babel/parser": "^7.24.5", + "@babel/types": "^7.24.5", + "@emotion/react": "^11.11.4", + "@octokit/rest": "^20.1.1", "@storybook/addon-a11y": "^6.5.16", "@storybook/addon-actions": "^6.5.13", "@storybook/addon-essentials": "^6.5.13", @@ -85,47 +86,34 @@ "@types/node": "^18.15.3", "@types/react": "^18.0.35", "@types/react-dom": "^18.0.11", - "@types/yauzl": "^2.10.0", - "@types/yazl": "^2.4.2", - "concurrently": "^8.0.1", - "copyfiles": "^2.4.1", + "@types/yauzl": "^2.10.3", + "@vercel/ncc": "^0.38.1", + "chalk": "^4.1.2", + "cheerio": "^1.0.0-rc.12", + "chokidar-cli": "^3.0.0", + "cli-select": "^1.1.2", "eslint-plugin-storybook": "^0.6.7", "husky": "^4.3.8", "lint-staged": "^11.0.0", - "powerhooks": "^0.26.7", - "prettier": "^2.3.0", + "magic-string": "^0.30.7", + "make-fetch-happen": "^11.0.3", + "powerhooks": "^1.0.10", + "prettier": "^3.2.5", "properties-parser": "^0.3.1", "react": "^18.2.0", "react-dom": "^18.2.0", - "rimraf": "^3.0.2", - "scripting-tools": "^0.19.13", - "storybook-dark-mode": "^1.1.2", - "ts-node": "^10.9.1", - "tsc-alias": "^1.8.3", - "tss-react": "^4.8.2", - "typescript": "^4.9.1-beta", - "vitest": "^0.29.8", - "zod-to-json-schema": "^3.20.4", - "vite": "^5.0.12" - }, - "dependencies": { - "@babel/generator": "^7.22.9", - "@babel/parser": "^7.22.7", - "@babel/types": "^7.22.5", - "@octokit/rest": "^18.12.0", - "cheerio": "^1.0.0-rc.5", - "cli-select": "^1.1.2", - "evt": "^2.4.18", - "make-fetch-happen": "^11.0.3", - "minimal-polyfills": "^2.2.2", - "minimist": "^1.2.6", - "react-markdown": "^5.0.3", "recast": "^0.23.3", - "rfc4648": "^1.5.2", - "tsafe": "^1.6.0", + "run-exclusive": "^2.2.19", + "storybook-dark-mode": "^1.1.2", + "termost": "^v0.12.1", + "tsc-alias": "^1.8.10", + "tss-react": "^4.9.10", + "typescript": "^4.9.1-beta", + "vite": "^5.2.11", + "vitest": "^1.6.0", "yauzl": "^2.10.0", - "yazl": "^2.5.1", "zod": "^3.17.10", - "magic-string": "^0.30.7" + "evt": "^2.5.7", + "tsx": "^4.15.5" } } diff --git a/renovate.json b/renovate.json index 2a2e5c65..03ca117b 100644 --- a/renovate.json +++ b/renovate.json @@ -1,6 +1,6 @@ { "$schema": "https://docs.renovatebot.com/renovate-schema.json", - "baseBranches": ["main", "landingpage"], + "baseBranches": ["main"], "extends": ["config:base"], "dependencyDashboard": false, "bumpVersion": "patch", diff --git a/scripts/build-storybook.ts b/scripts/build-storybook.ts new file mode 100644 index 00000000..7f617736 --- /dev/null +++ b/scripts/build-storybook.ts @@ -0,0 +1,16 @@ +import * as child_process from "child_process"; +import { copyKeycloakResourcesToStorybookStaticDir } from "./copyKeycloakResourcesToStorybookStaticDir"; + +(async () => { + run("yarn build"); + + await copyKeycloakResourcesToStorybookStaticDir(); + + run("npx build-storybook"); +})(); + +function run(command: string, options?: { env?: NodeJS.ProcessEnv }) { + console.log(`$ ${command}`); + + child_process.execSync(command, { stdio: "inherit", ...options }); +} diff --git a/scripts/build.ts b/scripts/build.ts new file mode 100644 index 00000000..c32f2087 --- /dev/null +++ b/scripts/build.ts @@ -0,0 +1,176 @@ +import * as child_process from "child_process"; +import * as fs from "fs"; +import { join } from "path"; +import { assert } from "tsafe/assert"; +import { transformCodebase } from "../src/bin/tools/transformCodebase"; +import chalk from "chalk"; + +console.log(chalk.cyan("Building Keycloakify...")); + +const startTime = Date.now(); + +if (fs.existsSync(join("dist", "bin", "main.original.js"))) { + fs.renameSync( + join("dist", "bin", "main.original.js"), + join("dist", "bin", "main.js") + ); + + fs.readdirSync(join("dist", "bin")).forEach(fileBasename => { + if (/[0-9]\.index.js/.test(fileBasename) || fileBasename.endsWith(".node")) { + fs.rmSync(join("dist", "bin", fileBasename)); + } + }); +} + +run(`npx tsc -p ${join("src", "bin", "tsconfig.json")}`); + +if ( + !fs + .readFileSync(join("dist", "bin", "main.js")) + .toString("utf8") + .includes("__nccwpck_require__") +) { + fs.cpSync(join("dist", "bin", "main.js"), join("dist", "bin", "main.original.js")); +} + +run(`npx ncc build ${join("dist", "bin", "main.js")} -o ${join("dist", "ncc_out")}`); + +transformCodebase({ + srcDirPath: join("dist", "ncc_out"), + destDirPath: join("dist", "bin"), + transformSourceCode: ({ fileRelativePath, sourceCode }) => { + if (fileRelativePath === "index.js") { + return { + newFileName: "main.js", + modifiedSourceCode: sourceCode + }; + } + + return { modifiedSourceCode: sourceCode }; + } +}); + +fs.rmSync(join("dist", "ncc_out"), { recursive: true }); + +{ + let hasBeenPatched = false; + + fs.readdirSync(join("dist", "bin")).forEach(fileBasename => { + if (fileBasename !== "main.js" && !fileBasename.endsWith(".index.js")) { + return; + } + + const { hasBeenPatched: hasBeenPatched_i } = patchDeprecatedBufferApiUsage( + join("dist", "bin", fileBasename) + ); + + if (hasBeenPatched_i) { + hasBeenPatched = true; + } + }); + + assert(hasBeenPatched); +} + +fs.chmodSync( + join("dist", "bin", "main.js"), + fs.statSync(join("dist", "bin", "main.js")).mode | + fs.constants.S_IXUSR | + fs.constants.S_IXGRP | + fs.constants.S_IXOTH +); + +run(`npx tsc -p ${join("src", "tsconfig.json")}`); +run(`npx tsc-alias -p ${join("src", "tsconfig.json")}`); + +if (fs.existsSync(join("dist", "vite-plugin", "index.original.js"))) { + fs.renameSync( + join("dist", "vite-plugin", "index.original.js"), + join("dist", "vite-plugin", "index.js") + ); +} + +run(`npx tsc -p ${join("src", "vite-plugin", "tsconfig.json")}`); + +if ( + !fs + .readFileSync(join("dist", "vite-plugin", "index.js")) + .toString("utf8") + .includes("__nccwpck_require__") +) { + fs.cpSync( + join("dist", "vite-plugin", "index.js"), + join("dist", "vite-plugin", "index.original.js") + ); +} + +run( + `npx ncc build ${join("dist", "vite-plugin", "index.js")} -o ${join( + "dist", + "ncc_out" + )}` +); + +fs.readdirSync(join("dist", "ncc_out")).forEach(fileBasename => { + assert(!fileBasename.endsWith(".index.js")); + assert(!fileBasename.endsWith(".node")); +}); + +transformCodebase({ + srcDirPath: join("dist", "ncc_out"), + destDirPath: join("dist", "vite-plugin"), + transformSourceCode: ({ fileRelativePath, sourceCode }) => { + assert(fileRelativePath === "index.js"); + + return { modifiedSourceCode: sourceCode }; + } +}); + +fs.rmSync(join("dist", "ncc_out"), { recursive: true }); + +{ + const { hasBeenPatched } = patchDeprecatedBufferApiUsage( + join("dist", "vite-plugin", "index.js") + ); + + assert(hasBeenPatched); +} + +fs.rmSync(join("dist", "src"), { recursive: true, force: true }); + +fs.cpSync("src", join("dist", "src"), { recursive: true }); + +transformCodebase({ + srcDirPath: join("stories"), + destDirPath: join("dist", "stories"), + transformSourceCode: ({ fileRelativePath, sourceCode }) => { + if (!fileRelativePath.endsWith(".stories.tsx")) { + return undefined; + } + + return { modifiedSourceCode: sourceCode }; + } +}); + +console.log(chalk.green(`✓ built in ${((Date.now() - startTime) / 1000).toFixed(2)}s`)); + +function run(command: string) { + console.log(chalk.grey(`$ ${command}`)); + + child_process.execSync(command, { stdio: "inherit" }); +} + +function patchDeprecatedBufferApiUsage(filePath: string) { + const before = fs.readFileSync(filePath).toString("utf8"); + + const after = before.replace( + `var buffer = new Buffer(toRead);`, + `var buffer = Buffer.allocUnsafe ? Buffer.allocUnsafe(toRead) : new Buffer(toRead);` + ); + + fs.writeFileSync(filePath, Buffer.from(after, "utf8")); + + const hasBeenPatched = after !== before; + + return { hasBeenPatched }; +} diff --git a/scripts/copyKeycloakResourcesToStorybookStaticDir.ts b/scripts/copyKeycloakResourcesToStorybookStaticDir.ts new file mode 100644 index 00000000..b666c27b --- /dev/null +++ b/scripts/copyKeycloakResourcesToStorybookStaticDir.ts @@ -0,0 +1,18 @@ +import { join as pathJoin } from "path"; +import { copyKeycloakResourcesToPublic } from "../src/bin/shared/copyKeycloakResourcesToPublic"; +import { getProxyFetchOptions } from "../src/bin/tools/fetchProxyOptions"; +import { LOGIN_THEME_RESOURCES_FROMkEYCLOAK_VERSION_DEFAULT } from "../src/bin/shared/constants"; + +export async function copyKeycloakResourcesToStorybookStaticDir() { + await copyKeycloakResourcesToPublic({ + buildContext: { + cacheDirPath: pathJoin(__dirname, "..", "node_modules", ".cache", "scripts"), + fetchOptions: getProxyFetchOptions({ + npmConfigGetCwd: pathJoin(__dirname, "..") + }), + loginThemeResourcesFromKeycloakVersion: + LOGIN_THEME_RESOURCES_FROMkEYCLOAK_VERSION_DEFAULT, + publicDirPath: pathJoin(__dirname, "..", ".storybook", "static") + } + }); +} diff --git a/scripts/dump-keycloak-realm.ts b/scripts/dump-keycloak-realm.ts new file mode 100644 index 00000000..93cf1581 --- /dev/null +++ b/scripts/dump-keycloak-realm.ts @@ -0,0 +1,92 @@ +import { CONTAINER_NAME } from "../src/bin/shared/constants"; +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"; + +(async () => { + { + const dCompleted = new Deferred(); + + const child = child_process.spawn( + "docker", + [ + ...["exec", CONTAINER_NAME], + ...["/opt/keycloak/bin/kc.sh", "export"], + ...["--dir", "/tmp"], + ...["--realm", "myrealm"], + ...["--users", "realm_file"] + ], + { shell: true } + ); + + let output = ""; + + const onExit = (code: number | null) => { + dCompleted.reject(new Error(`Exited with code ${code}`)); + }; + + 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)); + + console.log(chalk.red(error.message)); + + console.log(output); + + process.exit(1); + } + } + + const keycloakMajorVersionNumber = SemVer.parse( + child_process + .execSync(`docker inspect --format '{{.Config.Image}}' ${CONTAINER_NAME}`) + .toString("utf8") + .trim() + .split(":")[1] + ).major; + + const targetFilePath = pathRelative( + process.cwd(), + pathJoin( + __dirname, + "..", + "src", + "bin", + "start-keycloak", + `myrealm-realm-${keycloakMajorVersionNumber}.json` + ) + ); + + run(`docker cp ${CONTAINER_NAME}:/tmp/myrealm-realm.json ${targetFilePath}`); + + console.log(`${chalk.green(`✓ Exported realm to`)} ${chalk.bold(targetFilePath)}`); +})(); + +function run(command: string) { + console.log(chalk.grey(`$ ${command}`)); + + return child_process.execSync(command, { stdio: "inherit" }); +} diff --git a/scripts/generate-i18n-messages.ts b/scripts/generate-i18n-messages.ts index aa899487..89affa4f 100644 --- a/scripts/generate-i18n-messages.ts +++ b/scripts/generate-i18n-messages.ts @@ -1,10 +1,18 @@ import "minimal-polyfills/Object.fromEntries"; import * as fs from "fs"; -import { join as pathJoin, relative as pathRelative, dirname as pathDirname, sep as pathSep } from "path"; +import { + join as pathJoin, + relative as pathRelative, + 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 { downloadBuiltinKeycloakTheme } from "../src/bin/download-builtin-keycloak-theme"; +import { downloadKeycloakDefaultTheme } from "../src/bin/shared/downloadKeycloakDefaultTheme"; import { getThisCodebaseRootDirPath } from "../src/bin/tools/getThisCodebaseRootDirPath"; -import { getLogger } from "../src/bin/tools/logger"; +import { deepAssign } from "../src/tools/deepAssign"; +import { getProxyFetchOptions } from "../src/bin/tools/fetchProxyOptions"; // NOTE: To run without argument when we want to generate src/i18n/generated_kcMessages files, // update the version array for generating for newer version. @@ -12,29 +20,23 @@ import { getLogger } from "../src/bin/tools/logger"; //@ts-ignore const propertiesParser = require("properties-parser"); -const isSilent = true; - -const logger = getLogger({ isSilent }); - async function main() { - const keycloakVersion = "23.0.4"; + const keycloakVersion = "24.0.4"; const thisCodebaseRootDirPath = getThisCodebaseRootDirPath(); - const tmpDirPath = pathJoin(thisCodebaseRootDirPath, "tmp_xImOef9dOd44"); - - fs.rmSync(tmpDirPath, { "recursive": true, "force": true }); - - fs.mkdirSync(tmpDirPath); - - fs.writeFileSync(pathJoin(tmpDirPath, ".gitignore"), Buffer.from("/*\n!.gitignore\n", "utf8")); - - await downloadBuiltinKeycloakTheme({ + const { defaultThemeDirPath } = await downloadKeycloakDefaultTheme({ keycloakVersion, - "destDirPath": tmpDirPath, - "buildOptions": { - "cacheDirPath": pathJoin(thisCodebaseRootDirPath, "node_modules", ".cache", "keycloakify"), - "npmWorkspaceRootDirPath": thisCodebaseRootDirPath + buildContext: { + cacheDirPath: pathJoin( + thisCodebaseRootDirPath, + "node_modules", + ".cache", + "keycloakify" + ), + fetchOptions: getProxyFetchOptions({ + npmConfigGetCwd: thisCodebaseRootDirPath + }) } }); @@ -43,12 +45,14 @@ async function main() { const record: { [typeOfPage: string]: { [language: string]: Dictionary } } = {}; { - const baseThemeDirPath = pathJoin(tmpDirPath, "base"); - const re = new RegExp(`^([^\\${pathSep}]+)\\${pathSep}messages\\${pathSep}messages_([^.]+).properties$`); + const baseThemeDirPath = pathJoin(defaultThemeDirPath, "base"); + const re = new RegExp( + `^([^\\${pathSep}]+)\\${pathSep}messages\\${pathSep}messages_([^.]+).properties$` + ); crawl({ - "dirPath": baseThemeDirPath, - "returnedPathsType": "relative to dirPath" + dirPath: baseThemeDirPath, + returnedPathsType: "relative to dirPath" }).forEach(filePath => { const match = filePath.match(re); @@ -59,35 +63,73 @@ async function main() { const [, typeOfPage, language] = match; (record[typeOfPage] ??= {})[language.replace(/_/g, "-")] = Object.fromEntries( - Object.entries(propertiesParser.parse(fs.readFileSync(pathJoin(baseThemeDirPath, filePath)).toString("utf8"))).map( - ([key, value]: any) => [key, value.replace(/''/g, "'")] + Object.entries( + propertiesParser.parse( + fs + .readFileSync(pathJoin(baseThemeDirPath, filePath)) + .toString("utf8") + ) as Record ) + .map(([key, value]) => [key, value.replace(/''/g, "'")]) + .map(([key, value]) => [ + key === "locale_pt_BR" ? "locale_pt-BR" : key, + value + ]) + .map(([key, value]) => [key, key === "termsText" ? "" : value]) ); }); } - fs.rmSync(tmpDirPath, { recursive: true, force: true }); - Object.keys(record).forEach(themeType => { - const recordForPageType = record[themeType]; - if (themeType !== "login" && themeType !== "account") { return; } - const baseMessagesDirPath = pathJoin(thisCodebaseRootDirPath, "src", themeType, "i18n", "baseMessages"); + const recordForThemeType = record[themeType]; - const languages = Object.keys(recordForPageType); + 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 messagesDirPath = pathJoin( + thisCodebaseRootDirPath, + "src", + themeType, + "i18n", + "messages_defaultSet" + ); const generatedFileHeader = [ - `//This code was automatically generated by running ${pathRelative(thisCodebaseRootDirPath, __filename)}`, + `//This code was automatically generated by running ${pathRelative( + thisCodebaseRootDirPath, + __filename + )}`, "//PLEASE DO NOT EDIT MANUALLY" ].join("\n"); languages.forEach(language => { - const filePath = pathJoin(baseMessagesDirPath, `${language}.ts`); + const filePath = pathJoin(messagesDirPath, `${language}.ts`); - fs.mkdirSync(pathDirname(filePath), { "recursive": true }); + fs.mkdirSync(pathDirname(filePath), { recursive: true }); fs.writeFileSync( filePath, @@ -96,7 +138,11 @@ async function main() { generatedFileHeader, "", "/* spell-checker: disable */", - `const messages= ${JSON.stringify(recordForPageType[language], null, 2)};`, + `const messages= ${JSON.stringify( + recordForThemeType[language], + null, + 2 + )};`, "", "export default messages;", "/* spell-checker: enable */" @@ -105,27 +151,30 @@ async function main() { ) ); - logger.log(`${filePath} wrote`); + //console.log(`${filePath} wrote`); }); fs.writeFileSync( - pathJoin(baseMessagesDirPath, "index.ts"), + pathJoin(messagesDirPath, "index.ts"), Buffer.from( [ generatedFileHeader, `import * as en from "./en";`, "", - "export async function getMessages(currentLanguageTag: string) {", - " const { default: messages } = await (() => {", + "export async function fetchMessages_defaultSet(currentLanguageTag: string) {", + " const { default: messages_defaultSet } = await (() => {", " switch (currentLanguageTag) {", ` case "en": return en;`, ...languages .filter(language => language !== "en") - .map(language => ` case "${language}": return import("./${language}");`), + .map( + language => + ` case "${language}": return import("./${language}");` + ), ' default: return { "default": {} };', " }", " })();", - " return messages;", + " return messages_defaultSet;", "}" ].join("\n"), "utf8" @@ -134,6 +183,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(); } diff --git a/src/bin/tools/grant-exec-perms.ts b/scripts/grant-exec-perms.ts similarity index 68% rename from src/bin/tools/grant-exec-perms.ts rename to scripts/grant-exec-perms.ts index 50abb0be..871eae65 100644 --- a/src/bin/tools/grant-exec-perms.ts +++ b/scripts/grant-exec-perms.ts @@ -1,17 +1,17 @@ -import { getThisCodebaseRootDirPath } from "./getThisCodebaseRootDirPath"; import { join as pathJoin } from "path"; import { constants } from "fs"; import { chmod, stat } from "fs/promises"; (async () => { - const thisCodebaseRootDirPath = getThisCodebaseRootDirPath(); + const thisCodebaseRootDirPath = pathJoin(__dirname, ".."); const { bin } = await import(pathJoin(thisCodebaseRootDirPath, "package.json")); const promises = Object.values(bin).map(async scriptPath => { const fullPath = pathJoin(thisCodebaseRootDirPath, scriptPath); const oldMode = (await stat(fullPath)).mode; - const newMode = oldMode | constants.S_IXUSR | constants.S_IXGRP | constants.S_IXOTH; + const newMode = + oldMode | constants.S_IXUSR | constants.S_IXGRP | constants.S_IXOTH; await chmod(fullPath, newMode); }); diff --git a/scripts/link-in-app.ts b/scripts/link-in-app.ts index eacb3214..da7661fc 100644 --- a/scripts/link-in-app.ts +++ b/scripts/link-in-app.ts @@ -2,78 +2,83 @@ import { execSync } from "child_process"; import { join as pathJoin, relative as pathRelative } from "path"; import { getThisCodebaseRootDirPath } from "../src/bin/tools/getThisCodebaseRootDirPath"; import * as fs from "fs"; +import * as os from "os"; const singletonDependencies: string[] = ["react", "@types/react"]; +// For example [ "@emotion" ] it's more convenient than +// having to list every sub emotion packages (@emotion/css @emotion/utils ...) +// in singletonDependencies +const namespaceSingletonDependencies: string[] = []; + const rootDirPath = getThisCodebaseRootDirPath(); +const commonThirdPartyDeps = [ + ...namespaceSingletonDependencies + .map(namespaceModuleName => + fs + .readdirSync(pathJoin(rootDirPath, "node_modules", namespaceModuleName)) + .map(submoduleName => `${namespaceModuleName}/${submoduleName}`) + ) + .reduce((prev, curr) => [...prev, ...curr], []), + ...singletonDependencies +]; + //NOTE: This is only required because of: https://github.com/garronej/ts-ci/blob/c0e207b9677523d4ec97fe672ddd72ccbb3c1cc4/README.md?plain=1#L54-L58 -fs.writeFileSync( - pathJoin(rootDirPath, "dist", "package.json"), - Buffer.from( - JSON.stringify( - (() => { - const packageJsonParsed = JSON.parse(fs.readFileSync(pathJoin(rootDirPath, "package.json")).toString("utf8")); +{ + let modifiedPackageJsonContent = fs + .readFileSync(pathJoin(rootDirPath, "package.json")) + .toString("utf8"); - return { - ...packageJsonParsed, - "main": packageJsonParsed["main"]?.replace(/^dist\//, ""), - "types": packageJsonParsed["types"]?.replace(/^dist\//, ""), - "module": packageJsonParsed["module"]?.replace(/^dist\//, ""), - "exports": !("exports" in packageJsonParsed) - ? undefined - : Object.fromEntries( - Object.entries(packageJsonParsed["exports"]).map(([key, value]) => [ - key, - (value as string).replace(/^\.\/dist\//, "./") - ]) - ) - }; - })(), - null, - 2 - ), - "utf8" - ) -); + modifiedPackageJsonContent = (() => { + const o = JSON.parse(modifiedPackageJsonContent); -fs.cpSync(pathJoin(rootDirPath, "src"), pathJoin(rootDirPath, "dist", "src"), { "recursive": true }); + delete o.files; -const commonThirdPartyDeps = (() => { - // For example [ "@emotion" ] it's more convenient than - // having to list every sub emotion packages (@emotion/css @emotion/utils ...) - // in singletonDependencies - const namespaceSingletonDependencies: string[] = []; + return JSON.stringify(o, null, 2); + })(); - return [ - ...namespaceSingletonDependencies - .map(namespaceModuleName => - fs - .readdirSync(pathJoin(rootDirPath, "node_modules", namespaceModuleName)) - .map(submoduleName => `${namespaceModuleName}/${submoduleName}`) - ) - .reduce((prev, curr) => [...prev, ...curr], []), - ...singletonDependencies - ]; -})(); + modifiedPackageJsonContent = modifiedPackageJsonContent + .replace(/"dist\//g, '"') + .replace(/"\.\/dist\//g, '"./') + .replace(/"!dist\//g, '"!') + .replace(/"!\.\/dist\//g, '"!./'); + + modifiedPackageJsonContent = JSON.stringify( + { ...JSON.parse(modifiedPackageJsonContent), version: "0.0.0" }, + null, + 4 + ); + + fs.writeFileSync( + pathJoin(rootDirPath, "dist", "package.json"), + Buffer.from(modifiedPackageJsonContent, "utf8") + ); +} const yarnGlobalDirPath = pathJoin(rootDirPath, ".yarn_home"); -fs.rmSync(yarnGlobalDirPath, { "recursive": true, "force": true }); +fs.rmSync(yarnGlobalDirPath, { recursive: true, force: true }); fs.mkdirSync(yarnGlobalDirPath); const execYarnLink = (params: { targetModuleName?: string; cwd: string }) => { const { targetModuleName, cwd } = params; - const cmd = ["yarn", "link", ...(targetModuleName !== undefined ? [targetModuleName] : ["--no-bin-links"])].join(" "); + const cmd = [ + "yarn", + "link", + ...(targetModuleName !== undefined ? [targetModuleName] : ["--no-bin-links"]) + ].join(" "); console.log(`$ cd ${pathRelative(rootDirPath, cwd) || "."} && ${cmd}`); execSync(cmd, { cwd, - "env": { + env: { ...process.env, - "HOME": yarnGlobalDirPath + ...(os.platform() === "win32" + ? { USERPROFILE: yarnGlobalDirPath } + : { HOME: yarnGlobalDirPath }) } }); }; @@ -89,7 +94,9 @@ const testAppPaths = (() => { return testAppPath; } - console.warn(`Skipping ${testAppName} since it cant be found here: ${testAppPath}`); + console.warn( + `Skipping ${testAppName} since it cant be found here: ${testAppPath}` + ); return undefined; }) @@ -101,7 +108,7 @@ if (testAppPaths.length === 0) { process.exit(-1); } -testAppPaths.forEach(testAppPath => execSync("yarn install", { "cwd": testAppPath })); +testAppPaths.forEach(testAppPath => execSync("yarn install", { cwd: testAppPath })); console.log("=== Linking common dependencies ==="); @@ -114,29 +121,37 @@ commonThirdPartyDeps.forEach(commonThirdPartyDep => { console.log(`${current}/${total} ${commonThirdPartyDep}`); const localInstallPath = pathJoin( - ...[rootDirPath, "node_modules", ...(commonThirdPartyDep.startsWith("@") ? commonThirdPartyDep.split("/") : [commonThirdPartyDep])] + ...[ + rootDirPath, + "node_modules", + ...(commonThirdPartyDep.startsWith("@") + ? commonThirdPartyDep.split("/") + : [commonThirdPartyDep]) + ] ); - execYarnLink({ "cwd": localInstallPath }); + execYarnLink({ cwd: localInstallPath }); }); commonThirdPartyDeps.forEach(commonThirdPartyDep => testAppPaths.forEach(testAppPath => execYarnLink({ - "cwd": testAppPath, - "targetModuleName": commonThirdPartyDep + cwd: testAppPath, + targetModuleName: commonThirdPartyDep }) ) ); console.log("=== Linking in house dependencies ==="); -execYarnLink({ "cwd": pathJoin(rootDirPath, "dist") }); +execYarnLink({ cwd: pathJoin(rootDirPath, "dist") }); testAppPaths.forEach(testAppPath => execYarnLink({ - "cwd": testAppPath, - "targetModuleName": JSON.parse(fs.readFileSync(pathJoin(rootDirPath, "package.json")).toString("utf8"))["name"] + cwd: testAppPath, + targetModuleName: JSON.parse( + fs.readFileSync(pathJoin(rootDirPath, "package.json")).toString("utf8") + )["name"] }) ); diff --git a/scripts/link-in-starter.ts b/scripts/link-in-starter.ts new file mode 100644 index 00000000..ed794a88 --- /dev/null +++ b/scripts/link-in-starter.ts @@ -0,0 +1,30 @@ +import * as child_process from "child_process"; +import * as fs from "fs"; +import { join } from "path"; +import { startRebuildOnSrcChange } from "./startRebuildOnSrcChange"; + +fs.rmSync("node_modules", { recursive: true, force: true }); +fs.rmSync("dist", { recursive: true, force: true }); +fs.rmSync(".yarn_home", { recursive: true, force: true }); + +run("yarn install"); +run("yarn build"); + +const starterName = "keycloakify-starter"; + +fs.rmSync(join("..", starterName, "node_modules"), { + recursive: true, + force: true +}); + +run("yarn install", { cwd: join("..", starterName) }); + +run(`npx tsx ${join("scripts", "link-in-app.ts")} ${starterName}`); + +startRebuildOnSrcChange(); + +function run(command: string, options?: { cwd: string }) { + console.log(`$ ${command}`); + + child_process.execSync(command, { stdio: "inherit", ...options }); +} diff --git a/scripts/start-storybook.ts b/scripts/start-storybook.ts new file mode 100644 index 00000000..2c966b57 --- /dev/null +++ b/scripts/start-storybook.ts @@ -0,0 +1,29 @@ +import * as child_process from "child_process"; +import { startRebuildOnSrcChange } from "./startRebuildOnSrcChange"; +import { copyKeycloakResourcesToStorybookStaticDir } from "./copyKeycloakResourcesToStorybookStaticDir"; + +(async () => { + run("yarn build"); + + await copyKeycloakResourcesToStorybookStaticDir(); + + { + const child = child_process.spawn("npx", ["start-storybook", "-p", "6006"], { + shell: true + }); + + child.stdout.on("data", data => process.stdout.write(data)); + + child.stderr.on("data", data => process.stderr.write(data)); + + child.on("exit", process.exit.bind(process)); + } + + startRebuildOnSrcChange(); +})(); + +function run(command: string, options?: { env?: NodeJS.ProcessEnv }) { + console.log(`$ ${command}`); + + child_process.execSync(command, { stdio: "inherit", ...options }); +} diff --git a/scripts/startRebuildOnSrcChange.ts b/scripts/startRebuildOnSrcChange.ts new file mode 100644 index 00000000..e630664f --- /dev/null +++ b/scripts/startRebuildOnSrcChange.ts @@ -0,0 +1,40 @@ +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(); + + const child = child_process.spawn("yarn", ["build"], { shell: true }); + + child.stdout.on("data", data => process.stdout.write(data)); + + child.stderr.on("data", data => process.stderr.write(data)); + + child.on("exit", () => dCompleted.resolve()); + + await dCompleted.pr; + + console.log("\n\n"); + }); + + console.log(chalk.green("Watching for changes in src/")); + + chokidar + .watch(["src", "stories"], { ignoreInitial: true }) + .on("all", async (event, path) => { + console.log(chalk.bold(`${event}: ${path}`)); + + await waitForDebounce(); + + runYarnBuild(); + }); +} diff --git a/scripts/test-keycloakify-starter.ts b/scripts/test-keycloakify-starter.ts deleted file mode 100644 index af512083..00000000 --- a/scripts/test-keycloakify-starter.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { execSync } from "child_process"; -import { existsSync, readFileSync, rmSync, writeFileSync } from "fs"; -import path from "path"; - -const testDir = "keycloakify_starter_test"; - -if (existsSync(path.join(process.cwd(), testDir))) { - rmSync(path.join(process.cwd(), testDir), { recursive: true }); -} -// Build and link package -execSync("yarn build"); -const pkgJSON = JSON.parse(readFileSync(path.join(process.cwd(), "package.json")).toString("utf8")); -pkgJSON.main = "./index.js"; -pkgJSON.types = "./index.d.ts"; -pkgJSON.scripts.prepare = undefined; -writeFileSync(path.join(process.cwd(), "dist", "package.json"), JSON.stringify(pkgJSON)); -// Wrapped in a try/catch because unlink errors if the package isn't linked -try { - execSync("yarn unlink"); -} catch {} -execSync("yarn link", { "cwd": path.join(process.cwd(), "dist") }); - -// Clone latest keycloakify-starter and link to keycloakify output -execSync(`git clone https://github.com/keycloakify/keycloakify-starter.git ${testDir}`); -execSync("yarn install", { "cwd": path.join(process.cwd(), testDir) }); -execSync("yarn link keycloakify", { "cwd": path.join(process.cwd(), testDir) }); - -//Ensure keycloak theme can be built -execSync("yarn build-keycloak-theme", { "cwd": path.join(process.cwd(), testDir) }); diff --git a/src/PUBLIC_URL.ts b/src/PUBLIC_URL.ts index af0f0bb3..6ed04151 100644 --- a/src/PUBLIC_URL.ts +++ b/src/PUBLIC_URL.ts @@ -1,12 +1,12 @@ -import { nameOfTheGlobal, basenameOfTheKeycloakifyResourcesDir } from "keycloakify/bin/constants"; +import { BASENAME_OF_KEYCLOAKIFY_RESOURCES_DIR } from "keycloakify/bin/shared/constants"; 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 = (() => { - const kcContext = (window as any)[nameOfTheGlobal]; + const kcContext = (window as any).kcContext; if (kcContext === undefined || process.env.NODE_ENV === "development") { assert( @@ -17,5 +17,5 @@ export const PUBLIC_URL = (() => { return process.env.PUBLIC_URL; } - return `${kcContext.url.resourcesPath}/${basenameOfTheKeycloakifyResourcesDir}`; + return `${kcContext.url.resourcesPath}/${BASENAME_OF_KEYCLOAKIFY_RESOURCES_DIR}`; })(); diff --git a/src/account/Fallback.tsx b/src/account/DefaultPage.tsx similarity index 85% rename from src/account/Fallback.tsx rename to src/account/DefaultPage.tsx index d19f3227..0424d694 100644 --- a/src/account/Fallback.tsx +++ b/src/account/DefaultPage.tsx @@ -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) { +export default function DefaultPage(props: PageProps) { const { kcContext, ...rest } = props; return ( diff --git a/src/account/kcContext/KcContext.ts b/src/account/KcContext/KcContext.ts similarity index 89% rename from src/account/kcContext/KcContext.ts rename to src/account/KcContext/KcContext.ts index 9b32c0cf..1b3a064d 100644 --- a/src/account/kcContext/KcContext.ts +++ b/src/account/KcContext/KcContext.ts @@ -1,7 +1,24 @@ -import type { AccountThemePageId } from "keycloakify/bin/keycloakify/generateFtl"; +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 } from "keycloakify/bin/constants"; + +export type ExtendKcContext< + KcContextExtension extends { properties?: Record }, + KcContextExtensionPerPage extends Record> +> = ValueOf<{ + [PageId in keyof KcContextExtensionPerPage | KcContext["pageId"]]: Extract< + KcContext, + { pageId: PageId } + > extends never + ? KcContext.Common & + KcContextExtension & { + pageId: PageId; + } & KcContextExtensionPerPage[PageId] + : Extract & + KcContextExtension & + KcContextExtensionPerPage[PageId]; +}>; export type KcContext = | KcContext.Password @@ -69,7 +86,10 @@ export declare namespace KcContext { * @param text to return * @return text if message exists for given field, else undefined */ - printIfExists: (fieldName: string, text: T) => T | undefined; + printIfExists: ( + fieldName: string, + text: T + ) => T | undefined; /** * Check if exists error message for given fields * @@ -98,7 +118,10 @@ export declare namespace KcContext { lastName?: string; username?: string; }; - properties: Record; + properties: {}; + "x-keycloakify": { + messages: Record; + }; }; export type Password = Common & { @@ -146,6 +169,7 @@ export declare namespace KcContext { algorithm: "HmacSHA1" | "HmacSHA256" | "HmacSHA512"; digits: number; lookAheadWindow: number; + getAlgorithmKey: () => string; } & ( | { type: "totp"; @@ -164,21 +188,6 @@ export declare namespace KcContext { }; mode?: "qr" | "manual" | undefined | null; isAppInitiatedAction: boolean; - url: { - accountUrl: string; - passwordUrl: string; - totpUrl: string; - socialUrl: string; - sessionsUrl: string; - applicationsUrl: string; - logUrl: string; - resourceUrl: string; - resourcesCommonPath: string; - resourcesPath: string; - /** @deprecated, not present in recent keycloak version apparently, use kcContext.referrer instead */ - referrerURI?: string; - getLogoutUrl: () => string; - }; stateChecker: string; }; diff --git a/src/account/KcContext/getKcContextMock.ts b/src/account/KcContext/getKcContextMock.ts new file mode 100644 index 00000000..8bf94bb2 --- /dev/null +++ b/src/account/KcContext/getKcContextMock.ts @@ -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 }, + KcContextExtensionPerPage extends Record<`${string}.ftl`, Record> +>(params: { + kcContextExtension: KcContextExtension; + kcContextExtensionPerPage: KcContextExtensionPerPage; + overrides?: DeepPartial; + overridesPerPage?: { + [PageId in AccountThemePageId | keyof KcContextExtensionPerPage]?: DeepPartial< + Extract< + ExtendKcContext, + { pageId: PageId } + > + >; + }; +}) { + const { + kcContextExtension, + kcContextExtensionPerPage, + overrides: overrides_global, + overridesPerPage: overridesPerPage_global + } = params; + + type KcContext = ExtendKcContext; + + function getKcContextMock< + PageId extends AccountThemePageId | keyof KcContextExtensionPerPage + >(params: { + pageId: PageId; + overrides?: DeepPartial>; + }): Extract { + 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 }; +} diff --git a/src/account/KcContext/index.ts b/src/account/KcContext/index.ts new file mode 100644 index 00000000..5990af8d --- /dev/null +++ b/src/account/KcContext/index.ts @@ -0,0 +1,2 @@ +export type { ExtendKcContext, KcContext } from "./KcContext"; +export { createGetKcContextMock } from "./getKcContextMock"; diff --git a/src/account/KcContext/kcContextMocks.ts b/src/account/KcContext/kcContextMocks.ts new file mode 100644 index 00000000..3f79faef --- /dev/null +++ b/src/account/KcContext/kcContextMocks.ts @@ -0,0 +1,193 @@ +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"; +import { BASE_URL } from "keycloakify/lib/BASE_URL"; + +const resourcesPath = `${BASE_URL}${KEYCLOAK_RESOURCES}/account/resources`; + +export const kcContextCommonMock: KcContext.Common = { + themeVersion: "0.0.0", + keycloakifyVersion: "0.0.0", + themeType: "account", + themeName: "my-theme-name", + url: { + resourcesPath, + resourcesCommonPath: `${resourcesPath}/${RESOURCES_COMMON}`, + resourceUrl: "#", + accountUrl: "#", + applicationsUrl: "#", + logoutUrl: "#", + getLogoutUrl: () => "#", + logUrl: "#", + passwordUrl: "#", + sessionsUrl: "#", + socialUrl: "#", + totpUrl: "#" + }, + realm: { + internationalizationEnabled: true, + userManagedAccessAllowed: true + }, + messagesPerField: { + printIfExists: () => { + return undefined; + }, + existsError: () => false, + get: key => `Fake error for ${key}`, + exists: () => false + }, + locale: { + supported: [ + /* spell-checker: disable */ + ["de", "Deutsch"], + ["no", "Norsk"], + ["ru", "Русский"], + ["sv", "Svenska"], + ["pt-BR", "Português (Brasil)"], + ["lt", "Lietuvių"], + ["en", "English"], + ["it", "Italiano"], + ["fr", "Français"], + ["zh-CN", "中文简体"], + ["es", "Español"], + ["cs", "Čeština"], + ["ja", "日本語"], + ["sk", "Slovenčina"], + ["pl", "Polski"], + ["ca", "Català"], + ["nl", "Nederlands"], + ["tr", "Türkçe"] + /* spell-checker: enable */ + ].map( + ([languageTag, label]) => + ({ + languageTag, + label, + url: "https://gist.github.com/garronej/52baaca1bb925f2296ab32741e062b8e" + }) as const + ), + currentLanguageTag: "en" + }, + features: { + authorization: true, + identityFederation: true, + log: true, + passwordUpdateSupported: true + }, + referrer: undefined, + account: { + firstName: "john", + lastName: "doe", + email: "john.doe@code.gouv.fr", + username: "doe_j" + }, + properties: {}, + "x-keycloakify": { + messages: {} + } +}; + +export const kcContextMocks: KcContext[] = [ + id({ + ...kcContextCommonMock, + pageId: "password.ftl", + password: { + passwordSet: true + }, + stateChecker: "state checker" + }), + id({ + ...kcContextCommonMock, + pageId: "account.ftl", + url: { + ...kcContextCommonMock.url, + referrerURI: "#", + accountUrl: "#" + }, + realm: { + ...kcContextCommonMock.realm, + registrationEmailAsUsername: true, + editUsernameAllowed: true + }, + stateChecker: "" + }), + id({ + ...kcContextCommonMock, + pageId: "sessions.ftl", + sessions: { + sessions: [ + { + ipAddress: "127.0.0.1", + started: new Date().toString(), + lastAccess: new Date().toString(), + expires: new Date().toString(), + clients: ["Chrome", "Firefox"], + id: "f8951177-817d-4a70-9c02-86d3c170fe51" + } + ] + }, + stateChecker: "g6WB1FaYnKotTkiy7ZrlxvFztSqS0U8jvHsOOOb2z4g" + }), + id({ + ...kcContextCommonMock, + pageId: "totp.ftl", + totp: { + enabled: true, + totpSecretEncoded: "KVVF G2BY N4YX S6LB IUYT K2LH IFYE 4SBV", + qrUrl: "#", + totpSecretQrCode: + "iVBORw0KGgoAAAANSUhEUgAAAPYAAAD2AQAAAADNaUdlAAACM0lEQVR4Xu3OIZJgOQwDUDFd2UxiurLAVnnbHw4YGDKtSiWOn4Gxf81//7r/+q8b4HfLGBZDK9d85NmNR+sB42sXvOYrN5P1DcgYYFTGfOlbzE8gzwy3euweGizw7cfdl34/GRhlkxjKNV+5AebPXPORX1JuB9x8ZfbyyD2y1krWAKsbMq1HnqQDaLfa77p4+MqvzEGSqvSAD/2IHW2yHaigR9tX3m8dDIYGcNf3f+gDpVBZbZU77zyJ6Rlcy+qoTMG887KAPD9hsh6a1Sv3gJUHGHUAxSMzj7zqDDe7Phmt2eG+8UsMxjRGm816MAO+8VMl1R1jGHOrZB/5Zo/WXAPgxixm9Mo96vDGrM1eOto8c4Ax4wF437mifOXlpiPzCnN7Y9l95NnEMxgMY9AAGA8fucH14Y1aVb6N/cqrmyh0BVht7k1e+bU8LK0Cg5vmVq9c5vHIjOfqxDIfeTraNVTwewa4wVe+SW5N+uP1qACeudUZbqGOfA6VZV750Noq2Xx3kpveV44ZelSV1V7KFHzkWyVrrlUwG0Pl9pWnoy3vsQoME6vKI69i5osVgwWzHT7zjmJtMcNUSVn1oYMd7ZodbgowZl45VG0uVuLPUr1yc79uaQBag/mqR34xhlWyHm1prplHboCWdZ4TeZjsK8+dI+jbz1C5hl65mcpgB5dhcj8+dGO+0Ko68+lD37JDD83dpDLzzK+TrQyaVwGj6pUboGV+7+AyN8An/pf84/7rv/4/1l4OCc/1BYMAAAAASUVORK5CYII=", + manualUrl: "#", + totpSecret: "G4nsI8lQagRMUchH8jEG", + otpCredentials: [], + supportedApplications: [ + "totpAppFreeOTPName", + "totpAppMicrosoftAuthenticatorName", + "totpAppGoogleName" + ], + policy: { + algorithm: "HmacSHA1", + digits: 6, + lookAheadWindow: 1, + type: "totp", + period: 30, + getAlgorithmKey: () => "SHA1" + } + }, + mode: "qr", + isAppInitiatedAction: false, + stateChecker: "" + }), + id({ + ...kcContextCommonMock, + pageId: "log.ftl", + log: { + events: [ + { + date: "2/21/2024, 1:28:39 PM", + event: "login", + ipAddress: "172.17.0.1", + client: "security-admin-console", + details: [{ key: "openid-connect", value: "admin" }] + } + ] + } + }), + id({ + ...kcContextCommonMock, + stateChecker: "", + pageId: "federatedIdentity.ftl", + federatedIdentity: { + identities: [ + { + providerId: "keycloak-oidc", + displayName: "keycloak-oidc", + userName: "John", + connected: true + } + ], + removeLinkPossible: true + } + }) +]; diff --git a/src/account/Template.tsx b/src/account/Template.tsx index 1c81f6ba..3b0c2fe4 100644 --- a/src/account/Template.tsx +++ b/src/account/Template.tsx @@ -1,34 +1,60 @@ -import { clsx } from "keycloakify/tools/clsx"; -import { usePrepareTemplate } from "keycloakify/lib/usePrepareTemplate"; -import { type TemplateProps } from "keycloakify/account/TemplateProps"; -import { useGetClassName } from "keycloakify/account/lib/useGetClassName"; -import type { KcContext } from "./kcContext"; -import type { I18n } from "./i18n"; +import { useEffect } from "react"; import { assert } from "keycloakify/tools/assert"; +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) { const { kcContext, i18n, doUseDefaultCss, active, classes, children } = props; - const { getClassName } = useGetClassName({ doUseDefaultCss, classes }); + const { kcClsx } = getKcClsx({ doUseDefaultCss, classes }); - const { msg, changeLocale, labelBySupportedLanguageTag, currentLanguageTag } = i18n; + const { msg, msgStr, getChangeLocaleUrl, labelBySupportedLanguageTag, currentLanguageTag } = i18n; const { locale, url, features, realm, message, referrer } = kcContext; - const { isReady } = usePrepareTemplate({ - "doFetchDefaultThemeResources": doUseDefaultCss, - "styles": [ - `${url.resourcesCommonPath}/node_modules/patternfly/dist/css/patternfly.min.css`, - `${url.resourcesCommonPath}/node_modules/patternfly/dist/css/patternfly-additions.min.css`, - `${url.resourcesPath}/css/account.css` - ], - "htmlClassName": getClassName("kcHtmlClass"), - "bodyClassName": clsx("admin-console", "user", getClassName("kcBodyClass")), - "htmlLangProperty": locale?.currentLanguageTag, - "documentTitle": i18n.msgStr("accountManagementTitle") + useEffect(() => { + document.title = msgStr("accountManagementTitle"); + }, []); + + useSetClassName({ + qualifiedName: "html", + className: kcClsx("kcHtmlClass") }); - if (!isReady) { + useSetClassName({ + qualifiedName: "body", + className: clsx("admin-console", "user", kcClsx("kcBodyClass")) + }); + + useEffect(() => { + const { currentLanguageTag } = locale ?? {}; + + if (currentLanguageTag === undefined) { + return; + } + + const html = document.querySelector("html"); + assert(html !== null); + html.lang = currentLanguageTag; + }, []); + + const { areAllStyleSheetsLoaded } = useInsertLinkTags({ + componentOrHookName: "Template", + hrefs: !doUseDefaultCss + ? [] + : [ + `${url.resourcesCommonPath}/node_modules/patternfly/dist/css/patternfly.min.css`, + `${url.resourcesCommonPath}/node_modules/patternfly/dist/css/patternfly-additions.min.css`, + `${url.resourcesPath}/css/account.css` + ] + }); + + if (!areAllStyleSheetsLoaded) { return null; } @@ -47,17 +73,13 @@ export default function Template(props: TemplateProps) { {realm.internationalizationEnabled && (assert(locale !== undefined), true) && locale.supported.length > 1 && (
  • - {/* eslint-disable-next-line jsx-a11y/anchor-is-valid */} {labelBySupportedLanguageTag[currentLanguageTag]} @@ -123,7 +145,12 @@ export default function Template(props: TemplateProps) {
    {message.type === "success" && } {message.type === "error" && } - {message.summary} +
    )} diff --git a/src/account/TemplateProps.ts b/src/account/TemplateProps.ts index c19f3312..0e5196d2 100644 --- a/src/account/TemplateProps.ts +++ b/src/account/TemplateProps.ts @@ -1,14 +1,13 @@ import type { ReactNode } from "react"; -import type { KcContext } from "./kcContext"; -import type { I18n } from "./i18n"; -export type TemplateProps = { +export type TemplateProps = { kcContext: KcContext; - i18n: I18nExtended; + i18n: I18n; doUseDefaultCss: boolean; - active: string; classes?: Partial>; children: ReactNode; + + active: string; }; export type ClassKey = diff --git a/src/account/i18n/GenericI18n.tsx b/src/account/i18n/GenericI18n.tsx new file mode 100644 index 00000000..a640ece0 --- /dev/null +++ b/src/account/i18n/GenericI18n.tsx @@ -0,0 +1,6 @@ +import type { GenericI18n_noJsx } from "./i18n"; + +export type GenericI18n = GenericI18n_noJsx & { + msg: (key: MessageKey, ...args: (string | undefined)[]) => JSX.Element; + advancedMsg: (key: string, ...args: (string | undefined)[]) => JSX.Element; +}; diff --git a/src/account/i18n/i18n.tsx b/src/account/i18n/i18n.tsx index 2183515e..628705d7 100644 --- a/src/account/i18n/i18n.tsx +++ b/src/account/i18n/i18n.tsx @@ -1,26 +1,24 @@ -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 { assert } from "tsafe/assert"; -import type { KcContext } from "../kcContext/KcContext"; -import { Markdown } from "keycloakify/tools/Markdown"; - -export const fallbackLanguageTag = "en"; +import messages_defaultSet_fallbackLanguage from "./messages_defaultSet/en"; +import { fetchMessages_defaultSet } from "./messages_defaultSet"; +import type { KcContext } from "../KcContext"; +import { FALLBACK_LANGUAGE_TAG } from "keycloakify/bin/shared/constants"; +import { id } from "tsafe/id"; export type KcContextLike = { locale?: { currentLanguageTag: string; supported: { languageTag: string; url: string; label: string }[]; }; + "x-keycloakify": { + messages: Record; + }; }; assert(); -export type MessageKey = keyof typeof fallbackMessages | keyof (typeof keycloakifyExtraMessages)[typeof fallbackLanguageTag]; - -export type GenericI18n = { +export type GenericI18n_noJsx = { /** * e.g: "en", "fr", "zh-CN" * @@ -28,11 +26,10 @@ export type GenericI18n = { */ currentLanguageTag: string; /** - * To call when the user switch language. - * This will cause the page to be reloaded, - * on next load currentLanguageTag === newLanguageTag + * Redirect to this url to change the language. + * After reload currentLanguageTag === newLanguageTag */ - changeLocale: (newLanguageTag: string) => never; + getChangeLocaleUrl: (newLanguageTag: string) => string; /** * e.g. "en" => "English", "fr" => "Français", ... * @@ -41,113 +38,165 @@ export type GenericI18n = { * */ labelBySupportedLanguageTag: Record; /** - * Examples assuming currentLanguageTag === "en" * - * msg("access-denied") === Access denied - * msg("impersonateTitleHtml", "Foo") === Foo Impersonate User - */ - msg: (key: MessageKey, ...args: (string | undefined)[]) => JSX.Element; - /** - * It's the same thing as msg() but instead of returning a JSX.Element it returns a string. - * It can be more convenient to manipulate strings but if there are HTML tags it wont render. + * Examples assuming currentLanguageTag === "en" + * { + * en: { + * "access-denied": "Access denied", + * "impersonateTitleHtml": "{0} Impersonate User", + * "bar": "Bar {0}" + * } + * } + * + * msgStr("access-denied") === "Access denied" + * msgStr("not-a-message-key") Throws an error * msgStr("impersonateTitleHtml", "Foo") === "Foo Impersonate User" + * msgStr("${bar}", "c") === "Bar <strong>XXX</strong>" + * The html in the arg is partially escaped for security reasons, it might come from an untrusted source, it's not safe to render it as html. */ msgStr: (key: MessageKey, ...args: (string | undefined)[]) => string; /** + * This is meant to be used when the key argument is variable, something that might have been configured by the user + * in the Keycloak admin for example. + * * Examples assuming currentLanguageTag === "en" - * advancedMsg("${access-denied} foo bar") === ${msgStr("access-denied")} foo bar === Access denied foo bar - * advancedMsg("${access-denied}") === advancedMsg("access-denied") === msg("access-denied") === Access denied - * advancedMsg("${not-a-message-key}") === advancedMsg(not-a-message-key") === not-a-message-key - */ - 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" + * { + * en: { + * "access-denied": "Access denied", + * } + * } + * + * advancedMsgStr("${access-denied}") === advancedMsgStr("access-denied") === msgStr("access-denied") === "Access denied" + * advancedMsgStr("${not-a-message-key}") === advancedMsgStr("not-a-message-key") === "not-a-message-key" */ advancedMsgStr: (key: string, ...args: (string | undefined)[]) => string; + + /** + * Initially the messages are in english (fallback language). + * The translations in the current language are being fetched dynamically. + * This property is true while the translations are being fetched. + */ + isFetchingTranslations: boolean; }; -export type I18n = GenericI18n; +export type MessageKey_defaultSet = keyof typeof messages_defaultSet_fallbackLanguage; -export function createUseI18n(extraMessages: { - [languageTag: string]: { [key in ExtraMessageKey]: string }; +export function createGetI18n(messagesByLanguageTag_themeDefined: { + [languageTag: string]: { [key in MessageKey_themeDefined]: string }; }) { - function useI18n(params: { kcContext: KcContextLike }): GenericI18n | null { + type I18n = GenericI18n_noJsx; + + type Result = { i18n: I18n; prI18n_currentLanguage: Promise | undefined }; + + const cachedResultByKcContext = new WeakMap(); + + function getI18n(params: { kcContext: KcContextLike }): Result { const { kcContext } = params; - const [i18n, setI18n] = useState | undefined>(undefined); + use_cache: { + const cachedResult = cachedResultByKcContext.get(kcContext); - const refHasStartedFetching = useRef(false); - - useEffect(() => { - if (refHasStartedFetching.current) { - return; + if (cachedResult === undefined) { + break use_cache; } - refHasStartedFetching.current = true; - - (async () => { - const { currentLanguageTag = fallbackLanguageTag } = kcContext.locale ?? {}; - - setI18n({ - ...createI18nTranslationFunctions({ - "fallbackMessages": { - ...fallbackMessages, - ...(keycloakifyExtraMessages[fallbackLanguageTag] ?? {}), - ...(extraMessages[fallbackLanguageTag] ?? {}) - } as any, - "messages": { - ...(await getMessages(currentLanguageTag)), - ...((keycloakifyExtraMessages as any)[currentLanguageTag] ?? {}), - ...(extraMessages[currentLanguageTag] ?? {}) - } as any - }), - currentLanguageTag, - "changeLocale": newLanguageTag => { - const { locale } = kcContext; - - assert(locale !== undefined, "Internationalization not enabled"); - - const targetSupportedLocale = locale.supported.find(({ languageTag }) => languageTag === newLanguageTag); - - assert(targetSupportedLocale !== undefined, `${newLanguageTag} need to be enabled in Keycloak admin`); - - window.location.href = targetSupportedLocale.url; - - assert(false, "never"); - }, - "labelBySupportedLanguageTag": Object.fromEntries( - (kcContext.locale?.supported ?? []).map(({ languageTag, label }) => [languageTag, label]) - ) - }); - })(); - }, []); - - return i18n ?? null; - } - - return { useI18n }; -} - -function createI18nTranslationFunctions(params: { - fallbackMessages: Record; - messages: Record; -}): Pick, "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; + return cachedResult; } - const message = messageOrUndefined; + const partialI18n: Pick = { + currentLanguageTag: kcContext.locale?.currentLanguageTag ?? FALLBACK_LANGUAGE_TAG, + getChangeLocaleUrl: newLanguageTag => { + const { locale } = kcContext; + + assert(locale !== undefined, "Internationalization not enabled"); + + const targetSupportedLocale = locale.supported.find(({ languageTag }) => languageTag === newLanguageTag); + + assert(targetSupportedLocale !== undefined, `${newLanguageTag} need to be enabled in Keycloak admin`); + + return targetSupportedLocale.url; + }, + labelBySupportedLanguageTag: Object.fromEntries((kcContext.locale?.supported ?? []).map(({ languageTag, label }) => [languageTag, label])) + }; + + const { createI18nTranslationFunctions } = createI18nTranslationFunctionsFactory({ + messages_themeDefined: + messagesByLanguageTag_themeDefined[partialI18n.currentLanguageTag] ?? + messagesByLanguageTag_themeDefined[FALLBACK_LANGUAGE_TAG] ?? + (() => { + const firstLanguageTag = Object.keys(messagesByLanguageTag_themeDefined)[0]; + if (firstLanguageTag === undefined) { + return undefined; + } + return messagesByLanguageTag_themeDefined[firstLanguageTag]; + })(), + messages_fromKcServer: kcContext["x-keycloakify"].messages + }); + + const isCurrentLanguageFallbackLanguage = partialI18n.currentLanguageTag === FALLBACK_LANGUAGE_TAG; + + const result: Result = { + i18n: { + ...partialI18n, + ...createI18nTranslationFunctions({ + messages_defaultSet_currentLanguage: isCurrentLanguageFallbackLanguage ? messages_defaultSet_fallbackLanguage : undefined + }), + isFetchingTranslations: !isCurrentLanguageFallbackLanguage + }, + prI18n_currentLanguage: isCurrentLanguageFallbackLanguage + ? undefined + : (async () => { + const messages_defaultSet_currentLanguage = await fetchMessages_defaultSet(partialI18n.currentLanguageTag); + + const i18n_currentLanguage: I18n = { + ...partialI18n, + ...createI18nTranslationFunctions({ messages_defaultSet_currentLanguage }), + isFetchingTranslations: false + }; + + // NOTE: This promise.resolve is just because without it we TypeScript + // gives a Variable 'result' is used before being assigned. error + await Promise.resolve().then(() => { + result.i18n = i18n_currentLanguage; + result.prI18n_currentLanguage = undefined; + }); + + return i18n_currentLanguage; + })() + }; + + cachedResultByKcContext.set(kcContext, result); + + return result; + } + + return { getI18n }; +} + +function createI18nTranslationFunctionsFactory(params: { + messages_themeDefined: Record | undefined; + messages_fromKcServer: Record; +}) { + const { messages_themeDefined, messages_fromKcServer } = params; + + function createI18nTranslationFunctions(params: { + messages_defaultSet_currentLanguage: Partial> | undefined; + }): Pick, "msgStr" | "advancedMsgStr"> { + const { messages_defaultSet_currentLanguage } = params; + + function resolveMsg(props: { key: string; args: (string | undefined)[] }): string | undefined { + const { key, args } = props; + + const message = + id>(messages_fromKcServer)[key] ?? + id | undefined>(messages_themeDefined)?.[key] ?? + id | undefined>(messages_defaultSet_currentLanguage)?.[key] ?? + id>(messages_defaultSet_fallbackLanguage)[key]; + + if (message === undefined) { + return undefined; + } - const messageWithArgsInjectedIfAny = (() => { const startIndex = message .match(/{[0-9]+}/g) ?.map(g => g.match(/{([0-9]+)}/)![1]) @@ -166,68 +215,36 @@ function createI18nTranslationFunctions(params: { return; } - messageWithArgsInjected = messageWithArgsInjected.replace(new RegExp(`\\{${i + startIndex}\\}`, "g"), arg); + messageWithArgsInjected = messageWithArgsInjected.replace( + new RegExp(`\\{${i + startIndex}\\}`, "g"), + arg.replace(//g, ">") + ); }); return messageWithArgsInjected; - })(); + } - return doRenderMarkdown ? ( - - {messageWithArgsInjectedIfAny} - - ) : ( - messageWithArgsInjectedIfAny - ); + function resolveMsgAdvanced(props: { key: string; args: (string | undefined)[] }): string { + const { key, args } = props; + + const match = key.match(/^\$\{(.+)\}$/); + + if (match === null) { + return key; + } + + return resolveMsg({ key: match[1], args }) ?? key; + } + + return { + msgStr: (key, ...args) => { + const resolvedMessage = resolveMsg({ key, args }); + assert(resolvedMessage !== undefined, `Message with key "${key}" not found`); + return resolvedMessage; + }, + advancedMsgStr: (key, ...args) => resolveMsgAdvanced({ key, args }) + }; } - 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 ? {keyUnwrappedFromCurlyBraces} : 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 { createI18nTranslationFunctions }; } - -const keycloakifyExtraMessages = { - "en": { - "shouldBeEqual": "{0} should be equal to {1}", - "shouldBeDifferent": "{0} should be different to {1}", - "shouldMatchPattern": "Pattern should match: `/{0}/`", - "mustBeAnInteger": "Must be an integer", - "notAValidOption": "Not a valid option", - "newPasswordSameAsOld": "New password must be different from the old one", - "passwordConfirmNotMatch": "Password confirmation does not match" - }, - "fr": { - /* spell-checker: disable */ - "shouldBeEqual": "{0} doit être égal à {1}", - "shouldBeDifferent": "{0} doit être différent de {1}", - "shouldMatchPattern": "Dois respecter le schéma: `/{0}/`", - "mustBeAnInteger": "Doit être un nombre entier", - "notAValidOption": "N'est pas une option valide", - - "logoutConfirmTitle": "Déconnexion", - "logoutConfirmHeader": "Êtes-vous sûr(e) de vouloir vous déconnecter ?", - "doLogout": "Se déconnecter", - "newPasswordSameAsOld": "Le nouveau mot de passe doit être différent de l'ancien", - "passwordConfirmNotMatch": "La confirmation du mot de passe ne correspond pas" - /* spell-checker: enable */ - } -}; diff --git a/src/account/i18n/index.ts b/src/account/i18n/index.ts index 36a7e50a..f492a960 100644 --- a/src/account/i18n/index.ts +++ b/src/account/i18n/index.ts @@ -1 +1,5 @@ -export type { I18n } from "./i18n"; +import type { GenericI18n } from "./GenericI18n"; +import type { MessageKey_defaultSet, KcContextLike } from "./i18n"; +export type { MessageKey_defaultSet, KcContextLike }; +export type I18n = GenericI18n; +export { createUseI18n } from "./useI18n"; diff --git a/src/account/i18n/useI18n.tsx b/src/account/i18n/useI18n.tsx new file mode 100644 index 00000000..fb19f1c4 --- /dev/null +++ b/src/account/i18n/useI18n.tsx @@ -0,0 +1,95 @@ +import { useEffect, useState } from "react"; +import { createGetI18n, type GenericI18n_noJsx, type KcContextLike, type MessageKey_defaultSet } from "./i18n"; +import { GenericI18n } from "./GenericI18n"; +import { Reflect } from "tsafe/Reflect"; + +export function createUseI18n(messagesByLanguageTag: { + [languageTag: string]: { [key in MessageKey_themeDefined]: string }; +}) { + type MessageKey = MessageKey_defaultSet | MessageKey_themeDefined; + + type I18n = GenericI18n; + + const { withJsx } = (() => { + const cache = new WeakMap, GenericI18n>(); + + function renderHtmlString(params: { htmlString: string; msgKey: string }): JSX.Element { + const { htmlString, msgKey } = params; + return ( +
    + ); + } + + function withJsx(i18n_noJsx: GenericI18n_noJsx): I18n { + use_cache: { + const i18n = cache.get(i18n_noJsx); + + if (i18n === undefined) { + break use_cache; + } + + return i18n; + } + + const i18n: I18n = { + ...i18n_noJsx, + msg: (msgKey, ...args) => renderHtmlString({ htmlString: i18n_noJsx.msgStr(msgKey, ...args), msgKey }), + advancedMsg: (msgKey, ...args) => renderHtmlString({ htmlString: i18n_noJsx.advancedMsgStr(msgKey, ...args), msgKey }) + }; + + cache.set(i18n_noJsx, i18n); + + return i18n; + } + + return { withJsx }; + })(); + + add_style: { + const attributeName = "data-kc-i18n"; + + // Check if already exists in head + if (document.querySelector(`style[${attributeName}]`) !== null) { + break add_style; + } + + const styleElement = document.createElement("style"); + styleElement.attributes.setNamedItem(document.createAttribute(attributeName)); + (styleElement.textContent = `[data-kc-msg] { display: inline-block; }`), document.head.prepend(styleElement); + } + + const { getI18n } = createGetI18n(messagesByLanguageTag); + + function useI18n(params: { kcContext: KcContextLike }): { i18n: I18n } { + const { kcContext } = params; + + const { i18n, prI18n_currentLanguage } = getI18n({ kcContext }); + + const [i18n_toReturn, setI18n_toReturn] = useState(withJsx(i18n)); + + useEffect(() => { + let isActive = true; + + prI18n_currentLanguage?.then(i18n => { + if (!isActive) { + return; + } + + setI18n_toReturn(withJsx(i18n)); + }); + + return () => { + isActive = false; + }; + }, []); + + return { i18n: i18n_toReturn }; + } + + return { useI18n, ofTypeI18n: Reflect() }; +} diff --git a/src/account/index.ts b/src/account/index.ts index b04046d7..cbe11f05 100644 --- a/src/account/index.ts +++ b/src/account/index.ts @@ -1,10 +1,3 @@ -import Fallback from "keycloakify/account/Fallback"; - -export default Fallback; - -export { getKcContext } from "keycloakify/account/kcContext/getKcContext"; -export { createGetKcContext } from "keycloakify/account/kcContext/createGetKcContext"; -export type { AccountThemePageId as PageId } from "keycloakify/bin/keycloakify/generateFtl"; -export { createUseI18n } from "keycloakify/account/i18n/i18n"; - -export type { PageProps } from "keycloakify/account/pages/PageProps"; +export type { ExtendKcContext } from "keycloakify/account/KcContext"; +export type { ClassKey } from "keycloakify/account/TemplateProps"; +export { createUseI18n } from "keycloakify/account/i18n"; diff --git a/src/account/kcContext/createGetKcContext.ts b/src/account/kcContext/createGetKcContext.ts deleted file mode 100644 index 93765ba2..00000000 --- a/src/account/kcContext/createGetKcContext.ts +++ /dev/null @@ -1,108 +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(params?: { - mockData?: readonly DeepPartial>[]; - mockProperties?: Record; -}) { - const { mockData, mockProperties } = params ?? {}; - - function getKcContext["pageId"] | undefined = undefined>(params?: { - mockPageId?: PageId; - storyPartialKcContext?: DeepPartial, { pageId: PageId }>>; - }): { - kcContext: PageId extends undefined - ? ExtendKcContext | undefined - : Extract, { pageId: PageId }>; - } { - const { mockPageId, storyPartialKcContext } = params ?? {}; - - const realKcContext = getKcContextFromWindow(); - - 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> = {}; - - 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 }; -} diff --git a/src/account/kcContext/getKcContext.ts b/src/account/kcContext/getKcContext.ts deleted file mode 100644 index e64429ab..00000000 --- a/src/account/kcContext/getKcContext.ts +++ /dev/null @@ -1,21 +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(params?: { - mockPageId?: ExtendKcContext["pageId"]; - mockData?: readonly DeepPartial>[]; -}): { kcContext: ExtendKcContext | undefined } { - const { mockPageId, mockData } = params ?? {}; - - const { getKcContext } = createGetKcContext({ - mockData - }); - - const { kcContext } = getKcContext({ mockPageId }); - - return { kcContext }; -} diff --git a/src/account/kcContext/getKcContextFromWindow.ts b/src/account/kcContext/getKcContextFromWindow.ts deleted file mode 100644 index 0c4de775..00000000 --- a/src/account/kcContext/getKcContextFromWindow.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { AndByDiscriminatingKey } from "keycloakify/tools/AndByDiscriminatingKey"; -import { nameOfTheGlobal } from "keycloakify/bin/constants"; -import type { KcContext } from "./KcContext"; - -export type ExtendKcContext = [KcContextExtension] extends [never] - ? KcContext - : AndByDiscriminatingKey<"pageId", KcContextExtension & KcContext.Common, KcContext>; - -export function getKcContextFromWindow(): ExtendKcContext | undefined { - return typeof window === "undefined" ? undefined : (window as any)[nameOfTheGlobal]; -} diff --git a/src/account/kcContext/index.ts b/src/account/kcContext/index.ts deleted file mode 100644 index 7ecbb2f3..00000000 --- a/src/account/kcContext/index.ts +++ /dev/null @@ -1 +0,0 @@ -export type { KcContext } from "./KcContext"; diff --git a/src/account/kcContext/kcContextMocks.ts b/src/account/kcContext/kcContextMocks.ts deleted file mode 100644 index 1053b367..00000000 --- a/src/account/kcContext/kcContextMocks.ts +++ /dev/null @@ -1,260 +0,0 @@ -import "minimal-polyfills/Object.fromEntries"; -import { resources_common, keycloak_resources } from "keycloakify/bin/constants"; -import { id } from "tsafe/id"; -import type { KcContext } from "./KcContext"; -import { BASE_URL } from "keycloakify/lib/BASE_URL"; - -const resourcesPath = `${BASE_URL}${keycloak_resources}/account/resources`; - -export const kcContextCommonMock: KcContext.Common = { - "themeVersion": "0.0.0", - "keycloakifyVersion": "0.0.0", - "themeType": "account", - "themeName": "my-theme-name", - "url": { - resourcesPath, - "resourcesCommonPath": `${resourcesPath}/${resources_common}`, - "resourceUrl": "#", - "accountUrl": "#", - "applicationsUrl": "#", - "logoutUrl": "#", - "getLogoutUrl": () => "#", - "logUrl": "#", - "passwordUrl": "#", - "sessionsUrl": "#", - "socialUrl": "#", - "totpUrl": "#" - }, - "realm": { - "internationalizationEnabled": true, - "userManagedAccessAllowed": true - }, - "messagesPerField": { - "printIfExists": () => { - return undefined; - }, - "existsError": () => false, - "get": key => `Fake error for ${key}`, - "exists": () => false - }, - "locale": { - "supported": [ - /* spell-checker: disable */ - { - "url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=de", - "label": "Deutsch", - "languageTag": "de" - }, - { - "url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=no", - "label": "Norsk", - "languageTag": "no" - }, - { - "url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=ru", - "label": "Русский", - "languageTag": "ru" - }, - { - "url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=sv", - "label": "Svenska", - "languageTag": "sv" - }, - { - "url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=pt-BR", - "label": "Português (Brasil)", - "languageTag": "pt-BR" - }, - { - "url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=lt", - "label": "Lietuvių", - "languageTag": "lt" - }, - { - "url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=en", - "label": "English", - "languageTag": "en" - }, - { - "url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=it", - "label": "Italiano", - "languageTag": "it" - }, - { - "url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=fr", - "label": "Français", - "languageTag": "fr" - }, - { - "url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=zh-CN", - "label": "中文简体", - "languageTag": "zh-CN" - }, - { - "url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=es", - "label": "Español", - "languageTag": "es" - }, - { - "url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=cs", - "label": "Čeština", - "languageTag": "cs" - }, - { - "url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=ja", - "label": "日本語", - "languageTag": "ja" - }, - { - "url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=sk", - "label": "Slovenčina", - "languageTag": "sk" - }, - { - "url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=pl", - "label": "Polski", - "languageTag": "pl" - }, - { - "url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=ca", - "label": "Català", - "languageTag": "ca" - }, - { - "url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=nl", - "label": "Nederlands", - "languageTag": "nl" - }, - { - "url": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg&execution=ee6c2834-46a4-4a20-a1b6-f6d6f6451b36&kc_locale=tr", - "label": "Türkçe", - "languageTag": "tr" - } - /* spell-checker: enable */ - ], - "currentLanguageTag": "en" - }, - "features": { - "authorization": true, - "identityFederation": true, - "log": true, - "passwordUpdateSupported": true - }, - "referrer": undefined, - "account": { - "firstName": "john", - "lastName": "doe", - "email": "john.doe@code.gouv.fr", - "username": "doe_j" - }, - "properties": { - "parent": "account-v1", - "kcButtonLargeClass": "btn-lg", - "locales": "ar,ca,cs,da,de,en,es,fr,fi,hu,it,ja,lt,nl,no,pl,pt-BR,ru,sk,sv,tr,zh-CN", - "kcButtonPrimaryClass": "btn-primary", - "accountResourceProvider": "account-v1", - "styles": - "css/account.css img/icon-sidebar-active.png img/logo.png resources-common/node_modules/patternfly/dist/css/patternfly.min.css resources-common/node_modules/patternfly/dist/css/patternfly-additions.min.css resources-common/node_modules/patternfly/dist/css/patternfly-additions.min.css", - "kcButtonClass": "btn", - "kcButtonDefaultClass": "btn-default" - } -}; - -export const kcContextMocks: KcContext[] = [ - id({ - ...kcContextCommonMock, - "pageId": "password.ftl", - "password": { - "passwordSet": true - }, - "stateChecker": "state checker" - }), - id({ - ...kcContextCommonMock, - "pageId": "account.ftl", - "url": { - ...kcContextCommonMock.url, - "referrerURI": "#", - "accountUrl": "#" - }, - "realm": { - ...kcContextCommonMock.realm, - "registrationEmailAsUsername": true, - "editUsernameAllowed": true - }, - "stateChecker": "" - }), - id({ - ...kcContextCommonMock, - "pageId": "sessions.ftl", - "sessions": { - "sessions": [ - { - "ipAddress": "127.0.0.1", - "started": new Date().toString(), - "lastAccess": new Date().toString(), - "expires": new Date().toString(), - "clients": ["Chrome", "Firefox"], - "id": "f8951177-817d-4a70-9c02-86d3c170fe51" - } - ] - }, - "stateChecker": "g6WB1FaYnKotTkiy7ZrlxvFztSqS0U8jvHsOOOb2z4g" - }), - id({ - ...kcContextCommonMock, - "pageId": "totp.ftl", - "totp": { - "enabled": true, - "totpSecretEncoded": "KVVF G2BY N4YX S6LB IUYT K2LH IFYE 4SBV", - "qrUrl": "#", - "totpSecretQrCode": - "iVBORw0KGgoAAAANSUhEUgAAAPYAAAD2AQAAAADNaUdlAAACM0lEQVR4Xu3OIZJgOQwDUDFd2UxiurLAVnnbHw4YGDKtSiWOn4Gxf81//7r/+q8b4HfLGBZDK9d85NmNR+sB42sXvOYrN5P1DcgYYFTGfOlbzE8gzwy3euweGizw7cfdl34/GRhlkxjKNV+5AebPXPORX1JuB9x8ZfbyyD2y1krWAKsbMq1HnqQDaLfa77p4+MqvzEGSqvSAD/2IHW2yHaigR9tX3m8dDIYGcNf3f+gDpVBZbZU77zyJ6Rlcy+qoTMG887KAPD9hsh6a1Sv3gJUHGHUAxSMzj7zqDDe7Phmt2eG+8UsMxjRGm816MAO+8VMl1R1jGHOrZB/5Zo/WXAPgxixm9Mo96vDGrM1eOto8c4Ax4wF437mifOXlpiPzCnN7Y9l95NnEMxgMY9AAGA8fucH14Y1aVb6N/cqrmyh0BVht7k1e+bU8LK0Cg5vmVq9c5vHIjOfqxDIfeTraNVTwewa4wVe+SW5N+uP1qACeudUZbqGOfA6VZV750Noq2Xx3kpveV44ZelSV1V7KFHzkWyVrrlUwG0Pl9pWnoy3vsQoME6vKI69i5osVgwWzHT7zjmJtMcNUSVn1oYMd7ZodbgowZl45VG0uVuLPUr1yc79uaQBag/mqR34xhlWyHm1prplHboCWdZ4TeZjsK8+dI+jbz1C5hl65mcpgB5dhcj8+dGO+0Ko68+lD37JDD83dpDLzzK+TrQyaVwGj6pUboGV+7+AyN8An/pf84/7rv/4/1l4OCc/1BYMAAAAASUVORK5CYII=", - "manualUrl": "#", - "totpSecret": "G4nsI8lQagRMUchH8jEG", - "otpCredentials": [], - "supportedApplications": ["totpAppFreeOTPName", "totpAppMicrosoftAuthenticatorName", "totpAppGoogleName"], - "policy": { - "algorithm": "HmacSHA1", - "digits": 6, - "lookAheadWindow": 1, - "type": "totp", - "period": 30 - } - }, - "mode": "qr", - "isAppInitiatedAction": false, - "stateChecker": "" - }), - id({ - ...kcContextCommonMock, - "pageId": "log.ftl", - "log": { - "events": [ - { - "date": "2/21/2024, 1:28:39 PM", - "event": "login", - "ipAddress": "172.17.0.1", - "client": "security-admin-console", - "details": [{ key: "openid-connect", value: "admin" }] - } - ] - } - }), - id({ - ...kcContextCommonMock, - "stateChecker": "", - "pageId": "federatedIdentity.ftl", - "federatedIdentity": { - "identities": [ - { - "providerId": "keycloak-oidc", - "displayName": "keycloak-oidc", - "userName": "John", - "connected": true - } - ], - "removeLinkPossible": true - } - }) -]; diff --git a/src/account/lib/kcClsx.ts b/src/account/lib/kcClsx.ts new file mode 100644 index 00000000..330e0c4f --- /dev/null +++ b/src/account/lib/kcClsx.ts @@ -0,0 +1,25 @@ +import { createGetKcClsx } from "keycloakify/lib/getKcClsx"; +import type { ClassKey } from "keycloakify/account/TemplateProps"; + +export const { getKcClsx } = createGetKcClsx({ + defaultClasses: { + kcHtmlClass: undefined, + kcBodyClass: undefined, + kcButtonClass: "btn", + kcContentWrapperClass: "row", + kcButtonPrimaryClass: "btn-primary", + kcButtonLargeClass: "btn-lg", + kcButtonDefaultClass: "btn-default", + kcFormClass: "form-horizontal", + kcFormGroupClass: "form-group", + kcInputWrapperClass: "col-xs-12 col-sm-12 col-md-12 col-lg-12", + kcLabelClass: "control-label", + kcInputClass: "form-control", + kcInputErrorMessageClass: + "pf-c-form__helper-text pf-m-error required kc-feedback-text" + } +}); + +export type { ClassKey }; + +export type KcClsx = ReturnType["kcClsx"]; diff --git a/src/account/lib/useGetClassName.ts b/src/account/lib/useGetClassName.ts deleted file mode 100644 index 15397ade..00000000 --- a/src/account/lib/useGetClassName.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { createUseClassName } from "keycloakify/lib/useGetClassName"; -import type { ClassKey } from "keycloakify/account/TemplateProps"; - -export const { useGetClassName } = createUseClassName({ - "defaultClasses": { - "kcHtmlClass": undefined, - "kcBodyClass": undefined, - "kcButtonClass": "btn", - "kcContentWrapperClass": "row", - "kcButtonPrimaryClass": "btn-primary", - "kcButtonLargeClass": "btn-lg", - "kcButtonDefaultClass": "btn-default", - "kcFormClass": "form-horizontal", - "kcFormGroupClass": "form-group", - "kcInputWrapperClass": "col-xs-12 col-sm-12 col-md-12 col-lg-12", - "kcLabelClass": "control-label", - "kcInputClass": "form-control", - "kcInputErrorMessageClass": "pf-c-form__helper-text pf-m-error required kc-feedback-text" - } -}); diff --git a/src/account/pages/Account.tsx b/src/account/pages/Account.tsx index a93f20e4..77b0724f 100644 --- a/src/account/pages/Account.tsx +++ b/src/account/pages/Account.tsx @@ -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, 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{msg("backToApplication")}}