diff options
-rw-r--r-- | .devcontainer/devcontainer.json | 38 | ||||
-rw-r--r-- | .eslintrc.cjs | 46 | ||||
-rw-r--r-- | .github/workflows/ci.yaml | 110 | ||||
-rw-r--r-- | .prettierignore | 6 | ||||
-rw-r--r-- | .prettierrc.json | 24 | ||||
-rw-r--r-- | .storybook/main.ts | 30 | ||||
-rw-r--r-- | .storybook/preview-head.html | 3 | ||||
-rw-r--r-- | .storybook/preview.ts | 16 | ||||
-rw-r--r-- | README.md | 45 | ||||
-rw-r--r-- | index.html | 21 | ||||
-rwxr-xr-x | package.json | 98 | ||||
-rw-r--r-- | src/account/KcApp.tsx | 27 | ||||
-rw-r--r-- | src/account/KcContext.ts | 5 | ||||
-rw-r--r-- | src/account/PageStory.tsx | 19 | ||||
-rw-r--r-- | src/account/Template.tsx | 159 | ||||
-rw-r--r-- | src/account/pages/Account.stories.tsx | 19 | ||||
-rw-r--r-- | src/account/pages/Password.stories.tsx | 29 | ||||
-rw-r--r-- | src/login/KcApp.tsx | 40 | ||||
-rw-r--r-- | src/login/KcContext.ts | 5 | ||||
-rw-r--r-- | src/login/PageStory.tsx | 13 | ||||
-rw-r--r-- | src/login/Template.tsx | 278 | ||||
-rw-r--r-- | src/login/UserProfileFormFields.tsx | 699 | ||||
-rw-r--r-- | src/login/pages/Register.stories.tsx | 1 | ||||
-rw-r--r-- | src/main.tsx | 23 | ||||
-rw-r--r-- | src/vite-env.d.ts | 9 | ||||
-rw-r--r-- | tsconfig.json | 42 | ||||
-rw-r--r-- | tsconfig.node.json | 16 | ||||
-rw-r--r-- | vite.config.ts | 18 | ||||
-rw-r--r-- | yarn.lock | 13 |
29 files changed, 412 insertions, 1440 deletions
diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 608e85a..ba1457b 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,23 +1,23 @@ { - "name": "Keycloakify Starter Devcontainer", - "image": "mcr.microsoft.com/devcontainers/typescript-node:1-20-bookworm", - "features": { - "ghcr.io/devcontainers/features/docker-in-docker:2": { - "moby": true, - "installDockerBuildx": true, - "version": "latest", - "dockerDashComposeVersion": "none" + "name": "Keycloakify Starter Devcontainer", + "image": "mcr.microsoft.com/devcontainers/typescript-node:1-20-bookworm", + "features": { + "ghcr.io/devcontainers/features/docker-in-docker:2": { + "moby": true, + "installDockerBuildx": true, + "version": "latest", + "dockerDashComposeVersion": "none" + }, + "ghcr.io/devcontainers-contrib/features/maven-sdkman:2": { + "version": "latest", + "jdkVersion": "latest", + "jdkDistro": "ms" + } }, - "ghcr.io/devcontainers-contrib/features/maven-sdkman:2": { - "version": "latest", - "jdkVersion": "latest", - "jdkDistro": "ms" + "postCreateCommand": "yarn install", + "customizations": { + "vscode": { + "extensions": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"] + } } - }, - "postCreateCommand": "yarn install", - "customizations": { - "vscode": { - "extensions": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"] - } - } } diff --git a/.eslintrc.cjs b/.eslintrc.cjs index ff5edfe..1dc4447 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -1,25 +1,27 @@ module.exports = { - root: true, - env: { browser: true, es2020: true }, - extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended', 'plugin:react-hooks/recommended', 'plugin:storybook/recommended'], - ignorePatterns: ['dist', '.eslintrc.cjs'], - parser: '@typescript-eslint/parser', - plugins: ['react-refresh'], - rules: { - 'react-refresh/only-export-components': [ - 'warn', - { allowConstantExport: true }, + root: true, + env: { browser: true, es2020: true }, + extends: [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended", + "plugin:react-hooks/recommended", + "plugin:storybook/recommended" ], - 'react-hooks/exhaustive-deps': 'off', - '@typescript-eslint/no-redeclare': 'off', - 'no-labels': 'off', - }, - overrides: [ - { - files: ['**/*.stories.*'], - rules: { - 'import/no-anonymous-default-export': 'off', - }, + ignorePatterns: ["dist", ".eslintrc.cjs"], + parser: "@typescript-eslint/parser", + plugins: ["react-refresh"], + rules: { + "react-refresh/only-export-components": ["warn", { allowConstantExport: true }], + "react-hooks/exhaustive-deps": "off", + "@typescript-eslint/no-redeclare": "off", + "no-labels": "off" }, - ], -} + overrides: [ + { + files: ["**/*.stories.*"], + rules: { + "import/no-anonymous-default-export": "off" + } + } + ] +}; diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 7f51c0f..894408d 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1,62 +1,60 @@ name: ci on: - push: - branches: - - main - pull_request: - branches: - - main + push: + branches: + - main + pull_request: + branches: + - main jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-node@v2 + - uses: bahmutov/npm-install@v1 + - run: yarn build + - run: npx keycloakify - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - uses: actions/setup-node@v2 - - uses: bahmutov/npm-install@v1 - - run: yarn build - - run: npx keycloakify - - check_if_version_upgraded: - name: Check if version upgrade - if: github.event_name == 'push' - runs-on: ubuntu-latest - needs: test - outputs: - from_version: ${{ steps.step1.outputs.from_version }} - to_version: ${{ steps.step1.outputs.to_version }} - is_upgraded_version: ${{ steps.step1.outputs.is_upgraded_version }} - steps: - - uses: garronej/[email protected] - id: step1 - with: - action_name: is_package_json_version_upgraded - branch: ${{ github.head_ref || github.ref }} - - create_github_release: - runs-on: ubuntu-latest - needs: check_if_version_upgraded - # We create a release only if the version have been upgraded and we are on a default branch - if: needs.check_if_version_upgraded.outputs.is_upgraded_version == 'true' && github.event_name == 'push' - steps: - - uses: actions/checkout@v2 - - uses: actions/setup-node@v2 - - uses: bahmutov/npm-install@v1 - - run: yarn build - - run: npx keycloakify - - run: mv dist_keycloak/target/retrocompat-*.jar retrocompat-keycloak-theme.jar - - run: mv dist_keycloak/target/*.jar keycloak-theme.jar - - uses: softprops/action-gh-release@v1 - with: - name: Release v${{ needs.check_if_version_upgraded.outputs.to_version }} - tag_name: v${{ needs.check_if_version_upgraded.outputs.to_version }} - target_commitish: ${{ github.head_ref || github.ref }} - generate_release_notes: true - draft: false - files: | - retrocompat-keycloak-theme.jar - keycloak-theme.jar - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + check_if_version_upgraded: + name: Check if version upgrade + if: github.event_name == 'push' + runs-on: ubuntu-latest + needs: test + outputs: + from_version: ${{ steps.step1.outputs.from_version }} + to_version: ${{ steps.step1.outputs.to_version }} + is_upgraded_version: ${{ steps.step1.outputs.is_upgraded_version }} + steps: + - uses: garronej/[email protected] + id: step1 + with: + action_name: is_package_json_version_upgraded + branch: ${{ github.head_ref || github.ref }} + create_github_release: + runs-on: ubuntu-latest + needs: check_if_version_upgraded + # We create a release only if the version have been upgraded and we are on a default branch + if: needs.check_if_version_upgraded.outputs.is_upgraded_version == 'true' && github.event_name == 'push' + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-node@v2 + - uses: bahmutov/npm-install@v1 + - run: yarn build + - run: npx keycloakify + - run: mv dist_keycloak/target/retrocompat-*.jar retrocompat-keycloak-theme.jar + - run: mv dist_keycloak/target/*.jar keycloak-theme.jar + - uses: softprops/action-gh-release@v1 + with: + name: Release v${{ needs.check_if_version_upgraded.outputs.to_version }} + tag_name: v${{ needs.check_if_version_upgraded.outputs.to_version }} + target_commitish: ${{ github.head_ref || github.ref }} + generate_release_notes: true + draft: false + files: | + retrocompat-keycloak-theme.jar + keycloak-theme.jar + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..e2a3254 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,6 @@ +node_modules/ +/dist/ +/dist_keycloak/ +/public/keycloak-resources/ +/.vscode/ +/.yarn_home/
\ No newline at end of file diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..aaeb4f2 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,24 @@ +{ + "printWidth": 90, + "tabWidth": 4, + "useTabs": false, + "semi": true, + "singleQuote": false, + "trailingComma": "none", + "bracketSpacing": true, + "arrowParens": "avoid", + "overrides": [ + { + "files": [ + "**/login/pages/*.tsx", + "**/account/pages/*.tsx", + "**/login/Template.tsx", + "**/account/Template.tsx", + "**/login/UserProfileFormFields.tsx" + ], + "options": { + "printWidth": 150 + } + } + ] +} diff --git a/.storybook/main.ts b/.storybook/main.ts index 305ca53..7da46a4 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -1,20 +1,20 @@ import type { StorybookConfig } from "@storybook/react-vite"; const config: StorybookConfig = { - stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"], - addons: [ - "@storybook/addon-links", - "@storybook/addon-essentials", - "@storybook/addon-onboarding", - "@storybook/addon-interactions", - ], - framework: { - name: "@storybook/react-vite", - options: {}, - }, - docs: { - autodocs: "tag", - }, - staticDirs: ["../public"] + stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"], + addons: [ + "@storybook/addon-links", + "@storybook/addon-essentials", + "@storybook/addon-onboarding", + "@storybook/addon-interactions" + ], + framework: { + name: "@storybook/react-vite", + options: {} + }, + docs: { + autodocs: "tag" + }, + staticDirs: ["../public"] }; export default config; diff --git a/.storybook/preview-head.html b/.storybook/preview-head.html new file mode 100644 index 0000000..17958c7 --- /dev/null +++ b/.storybook/preview-head.html @@ -0,0 +1,3 @@ +<script> + console.log("Hello world"); +</script> diff --git a/.storybook/preview.ts b/.storybook/preview.ts index 37914b1..9e8a01c 100644 --- a/.storybook/preview.ts +++ b/.storybook/preview.ts @@ -1,14 +1,14 @@ import type { Preview } from "@storybook/react"; const preview: Preview = { - parameters: { - controls: { - matchers: { - color: /(background|color)$/i, - date: /Date$/i, - }, - }, - }, + parameters: { + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/i + } + } + } }; export default preview; @@ -12,12 +12,12 @@ This repo constitutes an easily reusable setup for a Keycloak theme project OR for a Vite SPA React App that generates a Keycloak theme that goes along with it. -If you are only looking to create a Keycloak theme (and not a Keycloak theme and an App that share the same codebase) there are a lot of things that you can remove from this starter: [Please read this section of the README](#i-only-want-a-keycloak-theme). +If you are only looking to create a Keycloak theme (and not a Keycloak theme and an App that share the same codebase) there are a lot of things that you can remove from this starter: [Please read this section of the README](#i-only-want-a-keycloak-theme). -This starter is based on Vite. There is also [a Webpack based starter](https://github.com/keycloakify/keycloakify-starter-cra). +This starter is based on Vite. There is also [a Webpack based starter](https://github.com/keycloakify/keycloakify-starter-cra). > 📣 Looking for a library for redirecting your user to Keycloak when they click on the 'Login' button? -> Check out [oidc-spa](https://oidc-spa.dev) It's made by us and it's used in the [src/App](https://github.com/keycloakify/keycloakify-starter/tree/main/src/App) of this starter. +> Check out [oidc-spa](https://oidc-spa.dev) It's made by us and it's used in the [src/App](https://github.com/keycloakify/keycloakify-starter/tree/main/src/App) of this starter. # Quick start @@ -30,14 +30,14 @@ yarn # install dependencies (it's like npm install) yarn storybook # Start Storybook # This is by far the best way to develop your theme - # This enable to quickly see your pages in isolation and in different states. + # This enable to quickly see your pages in isolation and in different states. # You can create stories even for pages that you haven't explicitly overloaded. See src/keycloak-theme/login/pages/LoginResetPassword.stories.tsx # See Keycloakify's storybook for if you need a starting point for your stories: https://github.com/keycloakify/keycloakify/tree/main/stories yarn dev # See the Hello World app # Uncomment line 97 of src/keycloak-theme/login/kcContext where it reads: `mockPageId: "login.ftl"`, reload https://localhost:3000 # You can now see the login.ftl page with the mock data. (Don't forget to comment it back when you're done) - + # Install mvn (Maven) if not already done. On mac it's 'brew install maven', on Ubuntu/Debian it's 'sudo apt-get install maven' yarn build-keycloak-theme # Actually build the theme (generates the .jar to be imported in Keycloak) @@ -45,11 +45,11 @@ yarn build-keycloak-theme # Actually build the theme (generates the .jar to be i # your theme on a real Keycloak instance. npx eject-keycloak-page # Prompt that let you select the pages you want to customize - # This CLI tools is not guaranty to work, you can always copy pase pages + # This CLI tools is not guaranty to work, you can always copy pase pages # from the Keycloakify repo. # After you ejected a page you need to edit the src/keycloak-theme/login(or admin)/KcApp.tsx file - # You need to add a case in the switch for the page you just imported in your project. - # Look how it's done for the Login page and replicate for your new page. + # You need to add a case in the switch for the page you just imported in your project. + # Look how it's done for the Login page and replicate for your new page. npx initialize-email-theme # For initializing your email theme # Note that Keycloakify does not feature React integration for email yet. @@ -61,17 +61,17 @@ npx download-builtin-keycloak-theme # For downloading the default theme (as a re ## Using a development container This starter supports [development containers](https://containers.dev/). You can customize the configuration file [`.devcontainer.json`](./.devcontainer/devcontainer.json) to your liking. -Checkout [this video](https://www.youtube.com/watch?v=cB86HE_HIDc) to understand dev containers and how to set up your environment. +Checkout [this video](https://www.youtube.com/watch?v=cB86HE_HIDc) to understand dev containers and how to set up your environment. -# Theme variant +# Theme variant Keycloakify enables you to create different variant for a single theme. -This enable you to have a single jar that embed two or more theme variant. +This enable you to have a single jar that embed two or more theme variant. - + You can enable this feature by providing multiple theme name in the Keycloakify build option. -[See documentation](https://docs.keycloakify.dev/build-options#themename) +[See documentation](https://docs.keycloakify.dev/build-options#themename) # The CI workflow @@ -89,23 +89,22 @@ You can enable this feature by providing multiple theme name in the Keycloakify and when **releasing a new version**: `<org>/<repo>:latest` and `<org>/<repo>:X.Y.Z` [See on DockerHub](https://hub.docker.com/r/codegouvfr/keycloakify-starter) - +  +# The storybook -# The storybook - - + ```bash yarn yarn storybook ``` -# Docker +# Docker -Instructions for building and running the react app (`src/App`) that is collocated with our Keycloak theme. +Instructions for building and running the react app (`src/App`) that is collocated with our Keycloak theme. ```bash docker build -f Dockerfile -t keycloakify/keycloakify-starter:main . @@ -115,8 +114,8 @@ docker run -it -dp 8083:80 keycloakify/keycloakify-starter:main # I only want a Keycloak theme -If you are only looking to create a Keycloak theme and not a Theme + a React app, you can run theses few commands to refactor the template -and remove unnecessary files. +If you are only looking to create a Keycloak theme and not a Theme + a React app, you can run theses few commands to refactor the template +and remove unnecessary files. ```bash cd path/to/keycloakify-starter @@ -193,7 +192,7 @@ jobs: steps: - uses: garronej/[email protected] id: step1 - with: + with: action_name: is_package_json_version_upgraded branch: \${{ github.head_ref || github.ref }} @@ -226,4 +225,4 @@ jobs: EOF ``` -You can also remove `oidc-spa`, `powerhooks`, `zod` and `tsafe` from your dependencies. +You can also remove `oidc-spa`, `powerhooks`, `zod` and `tsafe` from your dependencies. @@ -1,15 +1,14 @@ <!doctype html> -<html lang="en"> - <head> - <meta charset="utf-8" /> - <meta name="viewport" content="width=device-width, initial-scale=1" /> +<html> + <head> + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> - <link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png"> - </head> - - <body> - <div id="root"></div> - <script type="module" src="/src/main.tsx"></script> - </body> + <link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" /> + </head> + <body> + <div id="root"></div> + <script type="module" src="/src/main.tsx"></script> + </body> </html> diff --git a/package.json b/package.json index 2cd6edb..3b4735d 100755 --- a/package.json +++ b/package.json @@ -1,50 +1,52 @@ { - "name": "keycloakify-starter", - "version": "6.1.10", - "description": "Starter for Keycloakify 10", - "repository": { - "type": "git", - "url": "git://github.com/codegouvfr/keycloakify-starter.git" - }, - "type": "module", - "scripts": { - "dev": "vite", - "build": "tsc && vite build", - "build-keycloak-theme": "yarn build && keycloakify", - "storybook": "storybook dev -p 6006", - "build-storybook": "storybook build" - }, - "license": "MIT", - "keywords": [], - "dependencies": { - "keycloakify": "10.0.0-rc.31", - "react": "^18.2.0", - "react-dom": "^18.2.0" - }, - "devDependencies": { - "@storybook/addon-essentials": "^8.0.2", - "@storybook/addon-interactions": "^8.0.2", - "@storybook/addon-links": "^8.0.2", - "@storybook/addon-onboarding": "^8.0.2", - "@storybook/blocks": "^8.0.2", - "@storybook/react": "^8.0.2", - "@storybook/react-vite": "^8.0.2", - "@storybook/test": "^8.0.2", - "@types/react": "^18.2.43", - "@types/react-dom": "^18.2.17", - "@typescript-eslint/eslint-plugin": "^6.14.0", - "@typescript-eslint/parser": "^6.14.0", - "@vitejs/plugin-react": "^4.2.1", - "eslint": "^8.55.0", - "eslint-plugin-react-hooks": "^4.6.0", - "eslint-plugin-react-refresh": "^0.4.5", - "eslint-plugin-storybook": "^0.8.0", - "storybook": "^8.0.2", - "typescript": "^5.2.2", - "vite": "^5.0.8" - }, - "_comment": "See https://github.com/storybookjs/storybook/issues/22431#issuecomment-1630086092", - "resolutions": { - "jackspeak": "2.1.1" - } + "name": "keycloakify-starter", + "version": "6.1.10", + "description": "Starter for Keycloakify 10", + "repository": { + "type": "git", + "url": "git://github.com/codegouvfr/keycloakify-starter.git" + }, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "build-keycloak-theme": "yarn build && keycloakify", + "storybook": "storybook dev -p 6006", + "build-storybook": "storybook build", + "format": "npx prettier . --write" + }, + "license": "MIT", + "keywords": [], + "dependencies": { + "keycloakify": "10.0.0-rc.33", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@storybook/addon-essentials": "^8.0.2", + "@storybook/addon-interactions": "^8.0.2", + "@storybook/addon-links": "^8.0.2", + "@storybook/addon-onboarding": "^8.0.2", + "@storybook/blocks": "^8.0.2", + "@storybook/react": "^8.0.2", + "@storybook/react-vite": "^8.0.2", + "@storybook/test": "^8.0.2", + "@types/react": "^18.2.43", + "@types/react-dom": "^18.2.17", + "@typescript-eslint/eslint-plugin": "^6.14.0", + "@typescript-eslint/parser": "^6.14.0", + "@vitejs/plugin-react": "^4.2.1", + "eslint": "^8.55.0", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-react-refresh": "^0.4.5", + "eslint-plugin-storybook": "^0.8.0", + "prettier": "3.3.1", + "storybook": "^8.0.2", + "typescript": "^5.2.2", + "vite": "^5.0.8" + }, + "_comment": "See https://github.com/storybookjs/storybook/issues/22431#issuecomment-1630086092", + "resolutions": { + "jackspeak": "2.1.1" + } } diff --git a/src/account/KcApp.tsx b/src/account/KcApp.tsx index 7920bba..ca2d8f3 100644 --- a/src/account/KcApp.tsx +++ b/src/account/KcApp.tsx @@ -1,9 +1,11 @@ import { Suspense, lazy } from "react"; -import type { KcContext } from "./kcContext"; +import type { PageProps } from "keycloakify/account"; +import type { KcContext } from "./KcContext"; import { useI18n } from "./i18n"; - +import Template from "keycloakify/account/Template"; const Fallback = lazy(() => import("keycloakify/account/Fallback")); -const Template = lazy(() => import("./Template")); + +const classes = {} satisfies PageProps["classes"]; export default function KcApp(props: { kcContext: KcContext }) { const { kcContext } = props; @@ -19,14 +21,17 @@ export default function KcApp(props: { kcContext: KcContext }) { {(() => { switch (kcContext.pageId) { default: - return <Fallback - {...{ - kcContext, - i18n, - Template, - }} - doUseDefaultCss={true} - /> + return ( + <Fallback + {...{ + kcContext, + i18n, + classes, + Template + }} + doUseDefaultCss={true} + /> + ); } })()} </Suspense> diff --git a/src/account/KcContext.ts b/src/account/KcContext.ts index 985ea03..7072fbd 100644 --- a/src/account/KcContext.ts +++ b/src/account/KcContext.ts @@ -5,4 +5,7 @@ export type KcContextExtraProperties = {}; export type KcContextExtraPropertiesPerPage = {}; -export type KcContext = ExtendKcContext<KcContextExtraProperties, KcContextExtraPropertiesPerPage>; +export type KcContext = ExtendKcContext< + KcContextExtraProperties, + KcContextExtraPropertiesPerPage +>; diff --git a/src/account/PageStory.tsx b/src/account/PageStory.tsx index e249d58..65bb37a 100644 --- a/src/account/PageStory.tsx +++ b/src/account/PageStory.tsx @@ -1,10 +1,10 @@ import type { DeepPartial } from "keycloakify/tools/DeepPartial"; -import type { KcContext } from "./kcContext"; +import type { KcContext } from "./KcContext"; import { createGetKcContextMock } from "keycloakify/account"; import type { KcContextExtraProperties, KcContextExtraPropertiesPerPage -} from "./kcContext"; +} from "./KcContext"; import KcApp from "./KcApp"; const kcContextExtraProperties: KcContextExtraProperties = {}; @@ -17,10 +17,14 @@ export const { getKcContextMock } = createGetKcContextMock({ overridesPerPage: {} }); -export function createPageStory<PageId extends KcContext["pageId"]>(params: { pageId: PageId }) { +export function createPageStory<PageId extends KcContext["pageId"]>(params: { + pageId: PageId; +}) { const { pageId } = params; - function PageStory(props: { kcContext?: DeepPartial<Extract<KcContext, { pageId: PageId }>> }) { + function PageStory(props: { + kcContext?: DeepPartial<Extract<KcContext, { pageId: PageId }>>; + }) { const { kcContext: overrides } = props; const kcContextMock = getKcContextMock({ @@ -28,13 +32,8 @@ export function createPageStory<PageId extends KcContext["pageId"]>(params: { pa overrides }); - return ( - <> - <KcApp kcContext={kcContextMock} /> - </> - ); + return <KcApp kcContext={kcContextMock} />; } return { PageStory }; } - diff --git a/src/account/Template.tsx b/src/account/Template.tsx deleted file mode 100644 index 9e6cd0d..0000000 --- a/src/account/Template.tsx +++ /dev/null @@ -1,159 +0,0 @@ -// Copy pasted from: https://github.com/keycloakify/keycloakify/blob/main/src/account/Template.tsx - -import { useEffect } from "react"; -import { assert } from "keycloakify/tools/assert"; -import { clsx } from "keycloakify/tools/clsx"; -import { useGetClassName } from "keycloakify/account/lib/useGetClassName"; -import { useInsertLinkTags } from "keycloakify/tools/useInsertLinkTags"; -import { useSetClassName } from "keycloakify/tools/useSetClassName"; -import type { TemplateProps } from "keycloakify/account/TemplateProps"; -import type { KcContext } from "./KcContext"; -import type { I18n } from "./i18n"; - -export default function Template(props: TemplateProps<KcContext, I18n>) { - const { kcContext, i18n, doUseDefaultCss, active, classes, children } = props; - - const { getClassName } = useGetClassName({ doUseDefaultCss, classes }); - - const { msg, msgStr, getChangeLocalUrl, labelBySupportedLanguageTag, currentLanguageTag } = i18n; - - const { locale, url, features, realm, message, referrer } = kcContext; - - useEffect(() => { - document.title = msgStr("accountManagementTitle"); - }, []); - - useSetClassName({ - qualifiedName: "html", - className: getClassName("kcHtmlClass") - }); - - useSetClassName({ - qualifiedName: "body", - className: clsx("admin-console", "user", getClassName("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; - } - - return ( - <> - <header className="navbar navbar-default navbar-pf navbar-main header"> - <nav className="navbar" role="navigation"> - <div className="navbar-header"> - <div className="container"> - <h1 className="navbar-title">Keycloak</h1> - </div> - </div> - <div className="navbar-collapse navbar-collapse-1"> - <div className="container"> - <ul className="nav navbar-nav navbar-utility"> - {realm.internationalizationEnabled && (assert(locale !== undefined), true) && locale.supported.length > 1 && ( - <li> - <div className="kc-dropdown" id="kc-locale-dropdown"> - <a href="#" id="kc-current-locale-link"> - {labelBySupportedLanguageTag[currentLanguageTag]} - </a> - <ul> - {locale.supported.map(({ languageTag }) => ( - <li key={languageTag} className="kc-dropdown-item"> - <a href={getChangeLocalUrl(languageTag)}>{labelBySupportedLanguageTag[languageTag]}</a> - </li> - ))} - </ul> - </div> - </li> - )} - {referrer?.url && ( - <li> - <a href={referrer.url} id="referrer"> - {msg("backTo", referrer.name)} - </a> - </li> - )} - <li> - <a href={url.getLogoutUrl()}>{msg("doSignOut")}</a> - </li> - </ul> - </div> - </div> - </nav> - </header> - - <div className="container"> - <div className="bs-sidebar col-sm-3"> - <ul> - <li className={clsx(active === "account" && "active")}> - <a href={url.accountUrl}>{msg("account")}</a> - </li> - {features.passwordUpdateSupported && ( - <li className={clsx(active === "password" && "active")}> - <a href={url.passwordUrl}>{msg("password")}</a> - </li> - )} - <li className={clsx(active === "totp" && "active")}> - <a href={url.totpUrl}>{msg("authenticator")}</a> - </li> - {features.identityFederation && ( - <li className={clsx(active === "social" && "active")}> - <a href={url.socialUrl}>{msg("federatedIdentity")}</a> - </li> - )} - <li className={clsx(active === "sessions" && "active")}> - <a href={url.sessionsUrl}>{msg("sessions")}</a> - </li> - <li className={clsx(active === "applications" && "active")}> - <a href={url.applicationsUrl}>{msg("applications")}</a> - </li> - {features.log && ( - <li className={clsx(active === "log" && "active")}> - <a href={url.logUrl}>{msg("log")}</a> - </li> - )} - {realm.userManagedAccessAllowed && features.authorization && ( - <li className={clsx(active === "authorization" && "active")}> - <a href={url.resourceUrl}>{msg("myResources")}</a> - </li> - )} - </ul> - </div> - - <div className="col-sm-9 content-area"> - {message !== undefined && ( - <div className={clsx("alert", `alert-${message.type}`)}> - {message.type === "success" && <span className="pficon pficon-ok"></span>} - {message.type === "error" && <span className="pficon pficon-error-circle-o"></span>} - <span className="kc-feedback-text">{message.summary}</span> - </div> - )} - - {children} - </div> - </div> - </> - ); -} diff --git a/src/account/pages/Account.stories.tsx b/src/account/pages/Account.stories.tsx new file mode 100644 index 0000000..86d2bab --- /dev/null +++ b/src/account/pages/Account.stories.tsx @@ -0,0 +1,19 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { createPageStory } from "../PageStory"; + +const pageId = "account.ftl"; + +const { PageStory } = createPageStory({ pageId }); + +const meta = { + title: `account/${pageId}`, + component: PageStory +} satisfies Meta<typeof PageStory>; + +export default meta; + +type Story = StoryObj<typeof meta>; + +export const Default: Story = { + render: () => <PageStory /> +}; diff --git a/src/account/pages/Password.stories.tsx b/src/account/pages/Password.stories.tsx new file mode 100644 index 0000000..a9b588d --- /dev/null +++ b/src/account/pages/Password.stories.tsx @@ -0,0 +1,29 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { createPageStory } from "../PageStory"; + +const pageId = "password.ftl"; + +const { PageStory } = createPageStory({ pageId }); + +const meta = { + title: `account/${pageId}`, + component: PageStory +} satisfies Meta<typeof PageStory>; + +export default meta; + +type Story = StoryObj<typeof meta>; + +export const Default: Story = { + render: () => <PageStory /> +}; + +export const WithMessage: Story = { + render: () => ( + <PageStory + kcContext={{ + message: { type: "success", summary: "This is a test message" } + }} + /> + ) +}; diff --git a/src/login/KcApp.tsx b/src/login/KcApp.tsx index 6ec7fc7..3dd158e 100644 --- a/src/login/KcApp.tsx +++ b/src/login/KcApp.tsx @@ -1,11 +1,13 @@ import { Suspense, lazy } from "react"; +import type { PageProps } from "keycloakify/login"; import type { KcContext } from "./KcContext"; import { useI18n } from "./i18n"; import { useDownloadTerms } from "keycloakify/login"; - +import Template from "keycloakify/login/Template"; const Fallback = lazy(() => import("keycloakify/login/Fallback")); -const Template = lazy(() => import("./Template")); -const UserProfileFormFields = lazy(() => import("./UserProfileFormFields")); +const UserProfileFormFields = lazy(() => import("keycloakify/login/UserProfileFormFields")); + +const classes = {} satisfies PageProps["classes"]; export default function KcApp(props: { kcContext: KcContext }) { const { kcContext } = props; @@ -15,12 +17,14 @@ export default function KcApp(props: { kcContext: KcContext }) { useDownloadTerms({ kcContext, downloadTermMarkdown: async ({ currentLanguageTag }) => { - const termsFileName = (() => { switch (currentLanguageTag) { - case "fr": return "fr.md"; - case "es": return "es.md"; - default: return "en.md"; + case "fr": + return "fr.md"; + case "es": + return "es.md"; + default: + return "en.md"; } })(); @@ -28,7 +32,6 @@ export default function KcApp(props: { kcContext: KcContext }) { const response = await fetch(`${import.meta.env}terms/${termsFileName}`); return response.text(); - } }); @@ -41,15 +44,18 @@ export default function KcApp(props: { kcContext: KcContext }) { {(() => { switch (kcContext.pageId) { default: - return <Fallback - {...{ - kcContext, - i18n, - Template, - UserProfileFormFields - }} - doUseDefaultCss={true} - /> + return ( + <Fallback + {...{ + kcContext, + i18n, + classes, + Template, + UserProfileFormFields + }} + doUseDefaultCss={true} + /> + ); } })()} </Suspense> diff --git a/src/login/KcContext.ts b/src/login/KcContext.ts index 1898a16..101f6b7 100644 --- a/src/login/KcContext.ts +++ b/src/login/KcContext.ts @@ -5,4 +5,7 @@ export type KcContextExtraProperties = {}; export type KcContextExtraPropertiesPerPage = {}; -export type KcContext = ExtendKcContext<KcContextExtraProperties, KcContextExtraPropertiesPerPage>; +export type KcContext = ExtendKcContext< + KcContextExtraProperties, + KcContextExtraPropertiesPerPage +>; diff --git a/src/login/PageStory.tsx b/src/login/PageStory.tsx index 1c89573..ca3388c 100644 --- a/src/login/PageStory.tsx +++ b/src/login/PageStory.tsx @@ -1,11 +1,11 @@ import type { DeepPartial } from "keycloakify/tools/DeepPartial"; -import type { KcContext } from "./kcContext"; +import type { KcContext } from "./KcContext"; import KcApp from "./KcApp"; import { createGetKcContextMock } from "keycloakify/login"; import type { KcContextExtraProperties, KcContextExtraPropertiesPerPage -} from "./kcContext"; +} from "./KcContext"; const kcContextExtraProperties: KcContextExtraProperties = {}; const kcContextExtraPropertiesPerPage: KcContextExtraPropertiesPerPage = {}; @@ -17,10 +17,14 @@ export const { getKcContextMock } = createGetKcContextMock({ overridesPerPage: {} }); -export function createPageStory<PageId extends KcContext["pageId"]>(params: { pageId: PageId }) { +export function createPageStory<PageId extends KcContext["pageId"]>(params: { + pageId: PageId; +}) { const { pageId } = params; - function PageStory(props: { kcContext?: DeepPartial<Extract<KcContext, { pageId: PageId }>> }) { + function PageStory(props: { + kcContext?: DeepPartial<Extract<KcContext, { pageId: PageId }>>; + }) { const { kcContext: overrides } = props; const kcContextMock = getKcContextMock({ @@ -37,4 +41,3 @@ export function createPageStory<PageId extends KcContext["pageId"]>(params: { pa return { PageStory }; } - diff --git a/src/login/Template.tsx b/src/login/Template.tsx deleted file mode 100644 index 5ac6965..0000000 --- a/src/login/Template.tsx +++ /dev/null @@ -1,278 +0,0 @@ -// Copy pasted from: https://github.com/keycloakify/keycloakify/blob/main/src/login/Template.tsx - -import { useEffect } from "react"; -import { assert } from "keycloakify/tools/assert"; -import { clsx } from "keycloakify/tools/clsx"; -import type { TemplateProps } from "keycloakify/login/TemplateProps"; -import { useGetClassName } from "keycloakify/login/lib/useGetClassName"; -import { useInsertScriptTags } from "keycloakify/tools/useInsertScriptTags"; -import { useInsertLinkTags } from "keycloakify/tools/useInsertLinkTags"; -import { useSetClassName } from "keycloakify/tools/useSetClassName"; -import type { KcContext } from "./KcContext"; -import type { I18n } from "./i18n"; - -export default function Template(props: TemplateProps<KcContext, I18n>) { - const { - displayInfo = false, - displayMessage = true, - displayRequiredFields = false, - headerNode, - showUsernameNode = null, - socialProvidersNode = null, - infoNode = null, - documentTitle, - bodyClassName, - kcContext, - i18n, - doUseDefaultCss, - classes, - children - } = props; - - const { getClassName } = useGetClassName({ doUseDefaultCss, classes }); - - const { msg, msgStr, getChangeLocalUrl, labelBySupportedLanguageTag, currentLanguageTag } = i18n; - - const { realm, locale, auth, url, message, isAppInitiatedAction, authenticationSession, scripts } = kcContext; - - useEffect(() => { - document.title = documentTitle ?? msgStr("loginTitle", kcContext.realm.displayName); - }, []); - - useSetClassName({ - qualifiedName: "html", - className: getClassName("kcHtmlClass") - }); - - useSetClassName({ - qualifiedName: "body", - className: bodyClassName ?? getClassName("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/patternfly/patternfly.min.css`, - `${url.resourcesCommonPath}/node_modules/patternfly/dist/css/patternfly.min.css`, - `${url.resourcesCommonPath}/node_modules/patternfly/dist/css/patternfly-additions.min.css`, - `${url.resourcesCommonPath}/lib/pficon/pficon.css`, - `${url.resourcesPath}/css/login.css` - ] - }); - - const { insertScriptTags } = useInsertScriptTags({ - componentOrHookName: "Template", - scriptTags: [ - { - type: "module", - src: `${url.resourcesPath}/js/menu-button-links.js` - }, - ...(authenticationSession === undefined - ? [] - : [ - { - type: "module", - textContent: [ - `import { checkCookiesAndSetTimer } from "${url.resourcesPath}/js/authChecker.js";`, - ``, - `checkCookiesAndSetTimer(`, - ` "${authenticationSession.authSessionId}",`, - ` "${authenticationSession.tabId}",`, - ` "${url.ssoLoginInOtherTabsUrl}"`, - `);` - ].join("\n") - } as const - ]), - ...scripts.map( - script => - ({ - type: "text/javascript", - src: script - }) as const - ) - ] - }); - - useEffect(() => { - if (areAllStyleSheetsLoaded) { - insertScriptTags(); - } - }, [areAllStyleSheetsLoaded]); - - if (!areAllStyleSheetsLoaded) { - return null; - } - - return ( - <div className={getClassName("kcLoginClass")}> - <div id="kc-header" className={getClassName("kcHeaderClass")}> - <div id="kc-header-wrapper" className={getClassName("kcHeaderWrapperClass")}> - {msg("loginTitleHtml", realm.displayNameHtml)} - </div> - </div> - - <div className={getClassName("kcFormCardClass")}> - <header className={getClassName("kcFormHeaderClass")}> - {realm.internationalizationEnabled && (assert(locale !== undefined), locale.supported.length > 1) && ( - <div className={getClassName("kcLocaleMainClass")} id="kc-locale"> - <div id="kc-locale-wrapper" className={getClassName("kcLocaleWrapperClass")}> - <div id="kc-locale-dropdown" className={clsx("menu-button-links", getClassName("kcLocaleDropDownClass"))}> - <button - tabIndex={1} - id="kc-current-locale-link" - aria-label={msgStr("languages")} - aria-haspopup="true" - aria-expanded="false" - aria-controls="language-switch1" - > - {labelBySupportedLanguageTag[currentLanguageTag]} - </button> - <ul - role="menu" - tabIndex={-1} - aria-labelledby="kc-current-locale-link" - aria-activedescendant="" - id="language-switch1" - className={getClassName("kcLocaleListClass")} - > - {locale.supported.map(({ languageTag }, i) => ( - <li key={languageTag} className={getClassName("kcLocaleListItemClass")} role="none"> - <a - role="menuitem" - id={`language-${i + 1}`} - className={getClassName("kcLocaleItemClass")} - href={getChangeLocalUrl(languageTag)} - > - {labelBySupportedLanguageTag[languageTag]} - </a> - </li> - ))} - </ul> - </div> - </div> - </div> - )} - {!(auth !== undefined && auth.showUsername && !auth.showResetCredentials) ? ( - displayRequiredFields ? ( - <div className={getClassName("kcContentWrapperClass")}> - <div className={clsx(getClassName("kcLabelWrapperClass"), "subtitle")}> - <span className="subtitle"> - <span className="required">*</span> - {msg("requiredFields")} - </span> - </div> - <div className="col-md-10"> - <h1 id="kc-page-title">{headerNode}</h1> - </div> - </div> - ) : ( - <h1 id="kc-page-title">{headerNode}</h1> - ) - ) : displayRequiredFields ? ( - <div className={getClassName("kcContentWrapperClass")}> - <div className={clsx(getClassName("kcLabelWrapperClass"), "subtitle")}> - <span className="subtitle"> - <span className="required">*</span> {msg("requiredFields")} - </span> - </div> - <div className="col-md-10"> - {showUsernameNode} - <div id="kc-username" className={getClassName("kcFormGroupClass")}> - <label id="kc-attempted-username">{auth.attemptedUsername}</label> - <a id="reset-login" href={url.loginRestartFlowUrl} aria-label={msgStr("restartLoginTooltip")}> - <div className="kc-login-tooltip"> - <i className={getClassName("kcResetFlowIcon")}></i> - <span className="kc-tooltip-text">{msg("restartLoginTooltip")}</span> - </div> - </a> - </div> - </div> - </div> - ) : ( - <> - {showUsernameNode} - <div id="kc-username" className={getClassName("kcFormGroupClass")}> - <label id="kc-attempted-username">{auth.attemptedUsername}</label> - <a id="reset-login" href={url.loginRestartFlowUrl} aria-label={msgStr("restartLoginTooltip")}> - <div className="kc-login-tooltip"> - <i className={getClassName("kcResetFlowIcon")}></i> - <span className="kc-tooltip-text">{msg("restartLoginTooltip")}</span> - </div> - </a> - </div> - </> - )} - </header> - <div id="kc-content"> - <div id="kc-content-wrapper"> - {/* App-initiated actions should not see warning messages about the need to complete the action during login. */} - {displayMessage && message !== undefined && (message.type !== "warning" || !isAppInitiatedAction) && ( - <div - className={clsx( - `alert-${message.type}`, - getClassName("kcAlertClass"), - `pf-m-${message?.type === "error" ? "danger" : message.type}` - )} - > - <div className="pf-c-alert__icon"> - {message.type === "success" && <span className={getClassName("kcFeedbackSuccessIcon")}></span>} - {message.type === "warning" && <span className={getClassName("kcFeedbackWarningIcon")}></span>} - {message.type === "error" && <span className={getClassName("kcFeedbackErrorIcon")}></span>} - {message.type === "info" && <span className={getClassName("kcFeedbackInfoIcon")}></span>} - </div> - <span - className={getClassName("kcAlertTitleClass")} - dangerouslySetInnerHTML={{ - __html: message.summary - }} - /> - </div> - )} - {children} - {auth !== undefined && auth.showTryAnotherWayLink && ( - <form id="kc-select-try-another-way-form" action={url.loginAction} method="post"> - <div className={getClassName("kcFormGroupClass")}> - <div className={getClassName("kcFormGroupClass")}> - <input type="hidden" name="tryAnotherWay" value="on" /> - <a - href="#" - id="try-another-way" - onClick={() => { - document.forms["kc-select-try-another-way-form" as never].submit(); - return false; - }} - > - {msg("doTryAnotherWay")} - </a> - </div> - </div> - </form> - )} - {socialProvidersNode} - {displayInfo && ( - <div id="kc-info" className={getClassName("kcSignUpClass")}> - <div id="kc-info-wrapper" className={getClassName("kcInfoAreaWrapperClass")}> - {infoNode} - </div> - </div> - )} - </div> - </div> - </div> - </div> - ); -} diff --git a/src/login/UserProfileFormFields.tsx b/src/login/UserProfileFormFields.tsx deleted file mode 100644 index 2b6264f..0000000 --- a/src/login/UserProfileFormFields.tsx +++ /dev/null @@ -1,699 +0,0 @@ -// Copy pasted from: https://github.com/keycloakify/keycloakify/blob/main/src/login/UserProfileFormFields.tsx - -import { useEffect, useReducer, Fragment } from "react"; -import { assert } from "tsafe/assert"; -import type { ClassKey } from "keycloakify/login/TemplateProps"; -import { - useUserProfileForm, - getButtonToDisplayForMultivaluedAttributeField, - type KcContextLike, - type FormAction, - type FormFieldError -} from "keycloakify/login/lib/useUserProfileForm"; -import type { Attribute } from "keycloakify/login/KcContext"; -import type { I18n } from "./i18n"; - -export type UserProfileFormFieldsProps = { - kcContext: KcContextLike; - i18n: I18n; - getClassName: (classKey: ClassKey) => string; - onIsFormSubmittableValueChange: (isFormSubmittable: boolean) => void; - BeforeField?: (props: BeforeAfterFieldProps) => JSX.Element | null; - AfterField?: (props: BeforeAfterFieldProps) => JSX.Element | null; -}; - -type BeforeAfterFieldProps = { - attribute: Attribute; - dispatchFormAction: React.Dispatch<FormAction>; - displayableErrors: FormFieldError[]; - i18n: I18n; - valueOrValues: string | string[]; -}; - -// NOTE: Enabled by default but it's a UX best practice to set it to false. -const doMakeUserConfirmPassword = true; - -export default function UserProfileFormFields(props: UserProfileFormFieldsProps) { - const { kcContext, onIsFormSubmittableValueChange, i18n, getClassName, BeforeField, AfterField } = props; - - const { advancedMsg } = i18n; - - const { - formState: { formFieldStates, isFormSubmittable }, - dispatchFormAction - } = useUserProfileForm({ - kcContext, - i18n, - doMakeUserConfirmPassword - }); - - useEffect(() => { - onIsFormSubmittableValueChange(isFormSubmittable); - }, [isFormSubmittable]); - - const groupNameRef = { current: "" }; - - return ( - <> - {formFieldStates.map(({ attribute, displayableErrors, valueOrValues }) => { - return ( - <Fragment key={attribute.name}> - <GroupLabel attribute={attribute} getClassName={getClassName} i18n={i18n} groupNameRef={groupNameRef} /> - {BeforeField !== undefined && ( - <BeforeField - attribute={attribute} - dispatchFormAction={dispatchFormAction} - displayableErrors={displayableErrors} - i18n={i18n} - valueOrValues={valueOrValues} - /> - )} - <div - className={getClassName("kcFormGroupClass")} - style={{ - display: attribute.name === "password-confirm" && !doMakeUserConfirmPassword ? "none" : undefined - }} - > - <div className={getClassName("kcLabelWrapperClass")}> - <label htmlFor={attribute.name} className={getClassName("kcLabelClass")}> - {advancedMsg(attribute.displayName ?? "")} - </label> - {attribute.required && <>*</>} - </div> - <div className={getClassName("kcInputWrapperClass")}> - {attribute.annotations.inputHelperTextBefore !== undefined && ( - <div - className={getClassName("kcInputHelperTextBeforeClass")} - id={`form-help-text-before-${attribute.name}`} - aria-live="polite" - > - {advancedMsg(attribute.annotations.inputHelperTextBefore)} - </div> - )} - <InputFiledByType - attribute={attribute} - valueOrValues={valueOrValues} - displayableErrors={displayableErrors} - formValidationDispatch={dispatchFormAction} - getClassName={getClassName} - i18n={i18n} - /> - <FieldErrors - attribute={attribute} - getClassName={getClassName} - displayableErrors={displayableErrors} - fieldIndex={undefined} - /> - {attribute.annotations.inputHelperTextAfter !== undefined && ( - <div - className={getClassName("kcInputHelperTextAfterClass")} - id={`form-help-text-after-${attribute.name}`} - aria-live="polite" - > - {advancedMsg(attribute.annotations.inputHelperTextAfter)} - </div> - )} - - {AfterField !== undefined && ( - <AfterField - attribute={attribute} - dispatchFormAction={dispatchFormAction} - displayableErrors={displayableErrors} - i18n={i18n} - valueOrValues={valueOrValues} - /> - )} - {/* NOTE: Downloading of html5DataAnnotations scripts is done in the useUserProfileForm hook */} - </div> - </div> - </Fragment> - ); - })} - </> - ); -} - -function GroupLabel(props: { - attribute: Attribute; - getClassName: UserProfileFormFieldsProps["getClassName"]; - i18n: I18n; - groupNameRef: { - current: string; - }; -}) { - const { attribute, getClassName, i18n, groupNameRef } = props; - - const { advancedMsg } = i18n; - - if (attribute.group?.name !== groupNameRef.current) { - groupNameRef.current = attribute.group?.name ?? ""; - - if (groupNameRef.current !== "") { - assert(attribute.group !== undefined); - - return ( - <div - className={getClassName("kcFormGroupClass")} - {...Object.fromEntries(Object.entries(attribute.group.html5DataAnnotations).map(([key, value]) => [`data-${key}`, value]))} - > - {(() => { - const groupDisplayHeader = attribute.group.displayHeader ?? ""; - const groupHeaderText = groupDisplayHeader !== "" ? advancedMsg(groupDisplayHeader) : attribute.group.name; - - return ( - <div className={getClassName("kcContentWrapperClass")}> - <label id={`header-${attribute.group.name}`} className={getClassName("kcFormGroupHeader")}> - {groupHeaderText} - </label> - </div> - ); - })()} - {(() => { - const groupDisplayDescription = attribute.group.displayDescription ?? ""; - - if (groupDisplayDescription !== "") { - const groupDescriptionText = advancedMsg(groupDisplayDescription); - - return ( - <div className={getClassName("kcLabelWrapperClass")}> - <label id={`description-${attribute.group.name}`} className={getClassName("kcLabelClass")}> - {groupDescriptionText} - </label> - </div> - ); - } - - return null; - })()} - </div> - ); - } - } - - return null; -} - -function FieldErrors(props: { - attribute: Attribute; - getClassName: UserProfileFormFieldsProps["getClassName"]; - displayableErrors: FormFieldError[]; - fieldIndex: number | undefined; -}) { - const { attribute, getClassName, fieldIndex } = props; - - const displayableErrors = props.displayableErrors.filter(error => error.fieldIndex === fieldIndex); - - if (displayableErrors.length === 0) { - return null; - } - - return ( - <span - id={`input-error-${attribute.name}${fieldIndex === undefined ? "" : `-${fieldIndex}`}`} - className={getClassName("kcInputErrorMessageClass")} - aria-live="polite" - > - {displayableErrors - .filter(error => error.fieldIndex === fieldIndex) - .map(({ errorMessage }, i, arr) => ( - <Fragment key={i}> - <span key={i}>{errorMessage}</span> - {arr.length - 1 !== i && <br />} - </Fragment> - ))} - </span> - ); -} - -type InputFiledByTypeProps = { - attribute: Attribute; - valueOrValues: string | string[]; - displayableErrors: FormFieldError[]; - formValidationDispatch: React.Dispatch<FormAction>; - getClassName: UserProfileFormFieldsProps["getClassName"]; - i18n: I18n; -}; - -function InputFiledByType(props: InputFiledByTypeProps) { - const { attribute, valueOrValues } = props; - - switch (attribute.annotations.inputType) { - case "textarea": - return <TextareaTag {...props} />; - case "select": - case "multiselect": - return <SelectTag {...props} />; - case "select-radiobuttons": - case "multiselect-checkboxes": - return <InputTagSelects {...props} />; - default: { - if (valueOrValues instanceof Array) { - return ( - <> - {valueOrValues.map((...[, i]) => ( - <InputTag key={i} {...props} fieldIndex={i} /> - ))} - </> - ); - } - - const inputNode = <InputTag {...props} fieldIndex={undefined} />; - - if (attribute.name === "password" || attribute.name === "password-confirm") { - return ( - <PasswordWrapper getClassName={props.getClassName} i18n={props.i18n} passwordInputId={attribute.name}> - {inputNode} - </PasswordWrapper> - ); - } - - return inputNode; - } - } -} - -function PasswordWrapper(props: { getClassName: (classKey: ClassKey) => string; i18n: I18n; passwordInputId: string; children: JSX.Element }) { - const { getClassName, i18n, passwordInputId, children } = props; - - const { msgStr } = i18n; - - const [isPasswordRevealed, toggleIsPasswordRevealed] = useReducer((isPasswordRevealed: boolean) => !isPasswordRevealed, false); - - useEffect(() => { - const passwordInputElement = document.getElementById(passwordInputId); - - assert(passwordInputElement instanceof HTMLInputElement); - - passwordInputElement.type = isPasswordRevealed ? "text" : "password"; - }, [isPasswordRevealed]); - - return ( - <div className={getClassName("kcInputGroup")}> - {children} - <button - type="button" - className={getClassName("kcFormPasswordVisibilityButtonClass")} - aria-label={msgStr(isPasswordRevealed ? "hidePassword" : "showPassword")} - aria-controls={passwordInputId} - onClick={toggleIsPasswordRevealed} - > - <i - className={getClassName(isPasswordRevealed ? "kcFormPasswordVisibilityIconHide" : "kcFormPasswordVisibilityIconShow")} - aria-hidden - /> - </button> - </div> - ); -} - -function InputTag(props: InputFiledByTypeProps & { fieldIndex: number | undefined }) { - const { attribute, fieldIndex, getClassName, formValidationDispatch, valueOrValues, i18n, displayableErrors } = props; - - return ( - <> - <input - type={(() => { - const { inputType } = attribute.annotations; - - if (inputType?.startsWith("html5-")) { - return inputType.slice(6); - } - - return inputType ?? "text"; - })()} - id={attribute.name} - name={attribute.name} - value={(() => { - if (fieldIndex !== undefined) { - assert(valueOrValues instanceof Array); - return valueOrValues[fieldIndex]; - } - - assert(typeof valueOrValues === "string"); - - return valueOrValues; - })()} - className={getClassName("kcInputClass")} - aria-invalid={displayableErrors.find(error => error.fieldIndex === fieldIndex) !== undefined} - disabled={attribute.readOnly} - autoComplete={attribute.autocomplete} - placeholder={attribute.annotations.inputTypePlaceholder} - pattern={attribute.annotations.inputTypePattern} - size={attribute.annotations.inputTypeSize === undefined ? undefined : parseInt(`${attribute.annotations.inputTypeSize}`)} - maxLength={ - attribute.annotations.inputTypeMaxlength === undefined ? undefined : parseInt(`${attribute.annotations.inputTypeMaxlength}`) - } - minLength={ - attribute.annotations.inputTypeMinlength === undefined ? undefined : parseInt(`${attribute.annotations.inputTypeMinlength}`) - } - max={attribute.annotations.inputTypeMax} - min={attribute.annotations.inputTypeMin} - step={attribute.annotations.inputTypeStep} - {...Object.fromEntries(Object.entries(attribute.html5DataAnnotations ?? {}).map(([key, value]) => [`data-${key}`, value]))} - onChange={event => - formValidationDispatch({ - action: "update", - name: attribute.name, - valueOrValues: (() => { - if (fieldIndex !== undefined) { - assert(valueOrValues instanceof Array); - - return valueOrValues.map((value, i) => { - if (i === fieldIndex) { - return event.target.value; - } - - return value; - }); - } - - return event.target.value; - })() - }) - } - onBlur={() => - props.formValidationDispatch({ - action: "focus lost", - name: attribute.name, - fieldIndex: fieldIndex - }) - } - /> - {(() => { - if (fieldIndex === undefined) { - return null; - } - - assert(valueOrValues instanceof Array); - - const values = valueOrValues; - - return ( - <> - <FieldErrors - attribute={attribute} - getClassName={getClassName} - displayableErrors={displayableErrors} - fieldIndex={fieldIndex} - /> - <AddRemoveButtonsMultiValuedAttribute - attribute={attribute} - values={values} - fieldIndex={fieldIndex} - dispatchFormAction={formValidationDispatch} - i18n={i18n} - /> - </> - ); - })()} - </> - ); -} - -function AddRemoveButtonsMultiValuedAttribute(props: { - attribute: Attribute; - values: string[]; - fieldIndex: number; - dispatchFormAction: React.Dispatch<Extract<FormAction, { action: "update" }>>; - i18n: I18n; -}) { - const { attribute, values, fieldIndex, dispatchFormAction, i18n } = props; - - const { msg } = i18n; - - const { hasAdd, hasRemove } = getButtonToDisplayForMultivaluedAttributeField({ attribute, values, fieldIndex }); - - const idPostfix = `-${attribute.name}-${fieldIndex + 1}`; - - return ( - <> - {hasRemove && ( - <> - <button - id={`kc-remove${idPostfix}`} - type="button" - className="pf-c-button pf-m-inline pf-m-link" - onClick={() => - dispatchFormAction({ - action: "update", - name: attribute.name, - valueOrValues: values.filter((_, i) => i !== fieldIndex) - }) - } - > - {msg("remove")} - </button> - {hasAdd ? <> | </> : null} - </> - )} - {hasAdd && ( - <button - id={`kc-add${idPostfix}`} - type="button" - className="pf-c-button pf-m-inline pf-m-link" - onClick={() => - dispatchFormAction({ - action: "update", - name: attribute.name, - valueOrValues: [...values, ""] - }) - } - > - {msg("addValue")} - </button> - )} - </> - ); -} - -function InputTagSelects(props: InputFiledByTypeProps) { - const { attribute, formValidationDispatch, getClassName, valueOrValues } = props; - - const { advancedMsg } = props.i18n; - - const { classDiv, classInput, classLabel, inputType } = (() => { - const { inputType } = attribute.annotations; - - assert(inputType === "select-radiobuttons" || inputType === "multiselect-checkboxes"); - - switch (inputType) { - case "select-radiobuttons": - return { - inputType: "radio", - classDiv: getClassName("kcInputClassRadio"), - classInput: getClassName("kcInputClassRadioInput"), - classLabel: getClassName("kcInputClassRadioLabel") - }; - case "multiselect-checkboxes": - return { - inputType: "checkbox", - classDiv: getClassName("kcInputClassCheckbox"), - classInput: getClassName("kcInputClassCheckboxInput"), - classLabel: getClassName("kcInputClassCheckboxLabel") - }; - } - })(); - - const options = (() => { - walk: { - const { inputOptionsFromValidation } = attribute.annotations; - - if (inputOptionsFromValidation === undefined) { - break walk; - } - - const validator = (attribute.validators as Record<string, { options?: string[] }>)[inputOptionsFromValidation]; - - if (validator === undefined) { - break walk; - } - - if (validator.options === undefined) { - break walk; - } - - return validator.options; - } - - return attribute.validators.options?.options ?? []; - })(); - - return ( - <> - {options.map(option => ( - <div key={option} className={classDiv}> - <input - type={inputType} - id={`${attribute.name}-${option}`} - name={attribute.name} - value={option} - className={classInput} - aria-invalid={props.displayableErrors.length !== 0} - disabled={attribute.readOnly} - checked={valueOrValues instanceof Array ? valueOrValues.includes(option) : valueOrValues === option} - onChange={event => - formValidationDispatch({ - action: "update", - name: attribute.name, - valueOrValues: (() => { - const isChecked = event.target.checked; - - if (valueOrValues instanceof Array) { - const newValues = [...valueOrValues]; - - if (isChecked) { - newValues.push(option); - } else { - newValues.splice(newValues.indexOf(option), 1); - } - - return newValues; - } - - return event.target.checked ? option : ""; - })() - }) - } - onBlur={() => - formValidationDispatch({ - action: "focus lost", - name: attribute.name, - fieldIndex: undefined - }) - } - /> - <label - htmlFor={`${attribute.name}-${option}`} - className={`${classLabel}${attribute.readOnly ? ` ${getClassName("kcInputClassRadioCheckboxLabelDisabled")}` : ""}`} - > - {advancedMsg(option)} - </label> - </div> - ))} - </> - ); -} - -function TextareaTag(props: InputFiledByTypeProps) { - const { attribute, formValidationDispatch, getClassName, displayableErrors, valueOrValues } = props; - - assert(typeof valueOrValues === "string"); - - const value = valueOrValues; - - return ( - <textarea - id={attribute.name} - name={attribute.name} - className={getClassName("kcInputClass")} - aria-invalid={displayableErrors.length !== 0} - disabled={attribute.readOnly} - cols={attribute.annotations.inputTypeCols === undefined ? undefined : parseInt(`${attribute.annotations.inputTypeCols}`)} - rows={attribute.annotations.inputTypeRows === undefined ? undefined : parseInt(`${attribute.annotations.inputTypeRows}`)} - maxLength={attribute.annotations.inputTypeMaxlength === undefined ? undefined : parseInt(`${attribute.annotations.inputTypeMaxlength}`)} - value={value} - onChange={event => - formValidationDispatch({ - action: "update", - name: attribute.name, - valueOrValues: event.target.value - }) - } - onBlur={() => - formValidationDispatch({ - action: "focus lost", - name: attribute.name, - fieldIndex: undefined - }) - } - /> - ); -} - -function SelectTag(props: InputFiledByTypeProps) { - const { attribute, formValidationDispatch, getClassName, displayableErrors, i18n, valueOrValues } = props; - - const { advancedMsg } = i18n; - - const isMultiple = attribute.annotations.inputType === "multiselect"; - - return ( - <select - id={attribute.name} - name={attribute.name} - className={getClassName("kcInputClass")} - aria-invalid={displayableErrors.length !== 0} - disabled={attribute.readOnly} - multiple={isMultiple} - size={attribute.annotations.inputTypeSize === undefined ? undefined : parseInt(`${attribute.annotations.inputTypeSize}`)} - value={valueOrValues} - onChange={event => - formValidationDispatch({ - action: "update", - name: attribute.name, - valueOrValues: (() => { - if (isMultiple) { - return Array.from(event.target.selectedOptions).map(option => option.value); - } - - return event.target.value; - })() - }) - } - onBlur={() => - formValidationDispatch({ - action: "focus lost", - name: attribute.name, - fieldIndex: undefined - }) - } - > - {!isMultiple && <option value=""></option>} - {(() => { - const options = (() => { - walk: { - const { inputOptionsFromValidation } = attribute.annotations; - - if (inputOptionsFromValidation === undefined) { - break walk; - } - - assert(typeof inputOptionsFromValidation === "string"); - - const validator = (attribute.validators as Record<string, { options?: string[] }>)[inputOptionsFromValidation]; - - if (validator === undefined) { - break walk; - } - - if (validator.options === undefined) { - break walk; - } - - return validator.options; - } - - return attribute.validators.options?.options ?? []; - })(); - - return options.map(option => ( - <option key={option} value={option}> - {(() => { - if (attribute.annotations.inputOptionLabels !== undefined) { - const { inputOptionLabels } = attribute.annotations; - - return advancedMsg(inputOptionLabels[option] ?? option); - } - - if (attribute.annotations.inputOptionLabelsI18nPrefix !== undefined) { - return advancedMsg(`${attribute.annotations.inputOptionLabelsI18nPrefix}.${option}`); - } - - return option; - })()} - </option> - )); - })()} - </select> - ); -} diff --git a/src/login/pages/Register.stories.tsx b/src/login/pages/Register.stories.tsx index 41e7cd0..86ca040 100644 --- a/src/login/pages/Register.stories.tsx +++ b/src/login/pages/Register.stories.tsx @@ -1,4 +1,3 @@ - import type { Meta, StoryObj } from "@storybook/react"; import { createPageStory } from "../PageStory"; diff --git a/src/main.tsx b/src/main.tsx index 1ba776a..895ed89 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,24 +1,33 @@ /* eslint-disable react-refresh/only-export-components */ import { createRoot } from "react-dom/client"; import { StrictMode, lazy, Suspense } from "react"; -//import { getKcContextMock } from "./login/PageStory"; -//const kcContext = getKcContextMock({ pageId: "register.ftl", overrides: {} }); -const { kcContext } = window; const KcLoginThemeApp = lazy(() => import("./login/KcApp")); const KcAccountThemeApp = lazy(() => import("./account/KcApp")); +let { kcContext } = window; + +// NOTE: This is just to test a specific page when you run `yarn dev` +// however the recommended way to develope is to use the Storybook +if (kcContext === undefined) { + kcContext = (await import("./login/PageStory")).getKcContextMock({ + pageId: "register.ftl" + }); +} + createRoot(document.getElementById("root")!).render( <StrictMode> <Suspense> {(() => { switch (kcContext?.themeType) { - case "login": return <KcLoginThemeApp kcContext={kcContext} />; - case "account": return <KcAccountThemeApp kcContext={kcContext} />; - case undefined: return <h1>No Keycloak Context</h1>; + case "login": + return <KcLoginThemeApp kcContext={kcContext} />; + case "account": + return <KcAccountThemeApp kcContext={kcContext} />; + case undefined: + return <h1>No Keycloak Context</h1>; } })()} </Suspense> </StrictMode> ); - diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index cbf61da..37e9404 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -3,9 +3,8 @@ import type { KcContext as KcContextLogin } from "./login/kcContext"; import type { KcContext as KcContextAccount } from "./account/kcContext"; - declare global { - interface Window { - kcContext?: KcContextLogin | KcContextAccount; - } -}
\ No newline at end of file + interface Window { + kcContext?: KcContextLogin | KcContextAccount; + } +} diff --git a/tsconfig.json b/tsconfig.json index a7fc6fb..30d6ff1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,25 +1,25 @@ { - "compilerOptions": { - "target": "ES2020", - "useDefineForClassFields": true, - "lib": ["ES2020", "DOM", "DOM.Iterable"], - "module": "ESNext", - "skipLibCheck": true, + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, - /* Bundler mode */ - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "resolveJsonModule": true, - "isolatedModules": true, - "noEmit": true, - "jsx": "react-jsx", + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", - /* Linting */ - "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true - }, - "include": ["src"], - "references": [{ "path": "./tsconfig.node.json" }] + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] } diff --git a/tsconfig.node.json b/tsconfig.node.json index 42872c5..26063d8 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -1,10 +1,10 @@ { - "compilerOptions": { - "composite": true, - "skipLibCheck": true, - "module": "ESNext", - "moduleResolution": "bundler", - "allowSyntheticDefaultImports": true - }, - "include": ["vite.config.ts"] + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] } diff --git a/vite.config.ts b/vite.config.ts index 108187e..38af6ad 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,15 +1,11 @@ -import { defineConfig } from 'vite' -import react from '@vitejs/plugin-react' +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; import { keycloakify } from "keycloakify/vite-plugin"; - // https://vitejs.dev/config/ export default defineConfig({ - plugins: [ - react(), - keycloakify() - ], - build: { - sourcemap: true - } -}) + plugins: [react(), keycloakify()], + build: { + sourcemap: true + } +}); @@ -5011,10 +5011,10 @@ jsonfile@^6.0.1: optionalDependencies: graceful-fs "^4.1.6" - version "10.0.0-rc.31" - resolved "https://registry.yarnpkg.com/keycloakify/-/keycloakify-10.0.0-rc.31.tgz#4ccd4887de0f759ff91f5765a9011c77fbc2230f" - integrity sha512-UMDtVq4jxlihKPnp2OMo2FXTlAEl0PpdN8Bbk0yBxvxgvPuDXazWM2smi4tr48aTLGhx/fWdiyw1mvsOlcFvPA== + version "10.0.0-rc.33" + resolved "https://registry.yarnpkg.com/keycloakify/-/keycloakify-10.0.0-rc.33.tgz#2a522facaf3138e7c9b699e95ef45cfc73ab0296" + integrity sha512-rByUFHqsSQ1P9ZsnbCtB02rHfF38J4+dV0gr/oArAviLt6NauO2r3KoRKMtkeT/1OKCvkthvK7cloFWEjBDiBQ== dependencies: react-markdown "^5.0.3" tsafe "^1.6.6" @@ -5774,6 +5774,11 @@ prelude-ls@^1.2.1: resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g== + version "3.3.1" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.3.1.tgz#e68935518dd90bb7ec4821ba970e68f8de16e1ac" + integrity sha512-7CAwy5dRsxs8PHXT3twixW9/OEll8MLE0VRPCJyl7CkS6VHGPSlsVaWTiASPTyGyYRyApxlaWTzwUxVNrhcwDg== + prettier@^3.1.1: version "3.2.5" resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.2.5.tgz#e52bc3090586e824964a8813b09aba6233b28368" |