Migrate to Keycloakify v10

This commit is contained in:
Joseph Garrone 2024-06-06 05:26:06 +02:00
parent 030836d534
commit 59008f5b87
29 changed files with 412 additions and 1440 deletions

View File

@ -1,23 +1,23 @@
{ {
"name": "Keycloakify Starter Devcontainer", "name": "Keycloakify Starter Devcontainer",
"image": "mcr.microsoft.com/devcontainers/typescript-node:1-20-bookworm", "image": "mcr.microsoft.com/devcontainers/typescript-node:1-20-bookworm",
"features": { "features": {
"ghcr.io/devcontainers/features/docker-in-docker:2": { "ghcr.io/devcontainers/features/docker-in-docker:2": {
"moby": true, "moby": true,
"installDockerBuildx": true, "installDockerBuildx": true,
"version": "latest", "version": "latest",
"dockerDashComposeVersion": "none" "dockerDashComposeVersion": "none"
},
"ghcr.io/devcontainers-contrib/features/maven-sdkman:2": {
"version": "latest",
"jdkVersion": "latest",
"jdkDistro": "ms"
}
}, },
"ghcr.io/devcontainers-contrib/features/maven-sdkman:2": { "postCreateCommand": "yarn install",
"version": "latest", "customizations": {
"jdkVersion": "latest", "vscode": {
"jdkDistro": "ms" "extensions": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"]
}
} }
},
"postCreateCommand": "yarn install",
"customizations": {
"vscode": {
"extensions": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"]
}
}
} }

View File

@ -1,25 +1,27 @@
module.exports = { module.exports = {
root: true, root: true,
env: { browser: true, es2020: true }, env: { browser: true, es2020: true },
extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended', 'plugin:react-hooks/recommended', 'plugin:storybook/recommended'], extends: [
ignorePatterns: ['dist', '.eslintrc.cjs'], "eslint:recommended",
parser: '@typescript-eslint/parser', "plugin:@typescript-eslint/recommended",
plugins: ['react-refresh'], "plugin:react-hooks/recommended",
rules: { "plugin:storybook/recommended"
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
], ],
'react-hooks/exhaustive-deps': 'off', ignorePatterns: ["dist", ".eslintrc.cjs"],
'@typescript-eslint/no-redeclare': 'off', parser: "@typescript-eslint/parser",
'no-labels': 'off', plugins: ["react-refresh"],
}, rules: {
overrides: [ "react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
{ "react-hooks/exhaustive-deps": "off",
files: ['**/*.stories.*'], "@typescript-eslint/no-redeclare": "off",
rules: { "no-labels": "off"
'import/no-anonymous-default-export': 'off',
},
}, },
], overrides: [
} {
files: ["**/*.stories.*"],
rules: {
"import/no-anonymous-default-export": "off"
}
}
]
};

View File

@ -1,62 +1,60 @@
name: ci name: ci
on: on:
push: push:
branches: branches:
- main - main
pull_request: pull_request:
branches: branches:
- main - main
jobs: 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: check_if_version_upgraded:
runs-on: ubuntu-latest name: Check if version upgrade
steps: if: github.event_name == 'push'
- uses: actions/checkout@v2 runs-on: ubuntu-latest
- uses: actions/setup-node@v2 needs: test
- uses: bahmutov/npm-install@v1 outputs:
- run: yarn build from_version: ${{ steps.step1.outputs.from_version }}
- run: npx keycloakify to_version: ${{ steps.step1.outputs.to_version }}
is_upgraded_version: ${{ steps.step1.outputs.is_upgraded_version }}
check_if_version_upgraded: steps:
name: Check if version upgrade - uses: garronej/ts-ci@v2.1.2
if: github.event_name == 'push' id: step1
runs-on: ubuntu-latest with:
needs: test action_name: is_package_json_version_upgraded
outputs: branch: ${{ github.head_ref || github.ref }}
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/ts-ci@v2.1.2
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 }}
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 }}

6
.prettierignore Normal file
View File

@ -0,0 +1,6 @@
node_modules/
/dist/
/dist_keycloak/
/public/keycloak-resources/
/.vscode/
/.yarn_home/

24
.prettierrc.json Normal file
View File

@ -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
}
}
]
}

View File

@ -1,20 +1,20 @@
import type { StorybookConfig } from "@storybook/react-vite"; import type { StorybookConfig } from "@storybook/react-vite";
const config: StorybookConfig = { const config: StorybookConfig = {
stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"], stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
addons: [ addons: [
"@storybook/addon-links", "@storybook/addon-links",
"@storybook/addon-essentials", "@storybook/addon-essentials",
"@storybook/addon-onboarding", "@storybook/addon-onboarding",
"@storybook/addon-interactions", "@storybook/addon-interactions"
], ],
framework: { framework: {
name: "@storybook/react-vite", name: "@storybook/react-vite",
options: {}, options: {}
}, },
docs: { docs: {
autodocs: "tag", autodocs: "tag"
}, },
staticDirs: ["../public"] staticDirs: ["../public"]
}; };
export default config; export default config;

View File

@ -0,0 +1,3 @@
<script>
console.log("Hello world");
</script>

View File

@ -1,14 +1,14 @@
import type { Preview } from "@storybook/react"; import type { Preview } from "@storybook/react";
const preview: Preview = { const preview: Preview = {
parameters: { parameters: {
controls: { controls: {
matchers: { matchers: {
color: /(background|color)$/i, color: /(background|color)$/i,
date: /Date$/i, date: /Date$/i
}, }
}, }
}, }
}; };
export default preview; export default preview;

View File

@ -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 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. 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? > 📣 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 # Quick start
@ -30,14 +30,14 @@ yarn # install dependencies (it's like npm install)
yarn storybook # Start Storybook yarn storybook # Start Storybook
# This is by far the best way to develop your theme # 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 # 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 # 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 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 # 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) # 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' # 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) 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. # your theme on a real Keycloak instance.
npx eject-keycloak-page # Prompt that let you select the pages you want to customize 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. # from the Keycloakify repo.
# After you ejected a page you need to edit the src/keycloak-theme/login(or admin)/KcApp.tsx file # 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. # 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. # Look how it's done for the Login page and replicate for your new page.
npx initialize-email-theme # For initializing your email theme npx initialize-email-theme # For initializing your email theme
# Note that Keycloakify does not feature React integration for email yet. # 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 ## 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. 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. 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.
![Theme variant](https://content.gitbook.com/content/FcBKODbZbNDgm0rc6a9K/blobs/9iKgs2rv2Kfb2pbs4dRz/image.png) ![Theme variant](https://content.gitbook.com/content/FcBKODbZbNDgm0rc6a9K/blobs/9iKgs2rv2Kfb2pbs4dRz/image.png)
You can enable this feature by providing multiple theme name in the Keycloakify build option. 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 # 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` 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) [See on DockerHub](https://hub.docker.com/r/codegouvfr/keycloakify-starter)
![image](https://user-images.githubusercontent.com/6702424/229296422-9d522707-114e-4282-93f7-01ca38c3a1e0.png) ![image](https://user-images.githubusercontent.com/6702424/229296422-9d522707-114e-4282-93f7-01ca38c3a1e0.png)
![image](https://user-images.githubusercontent.com/6702424/229296556-a69f2dc9-4653-475c-9c89-d53cf33dc05a.png) ![image](https://user-images.githubusercontent.com/6702424/229296556-a69f2dc9-4653-475c-9c89-d53cf33dc05a.png)
# The storybook
# The storybook ![image](https://github.com/keycloakify/keycloakify/assets/6702424/a18ac1ff-dcfd-4b8c-baed-dcda5aa1d762)
![image](https://github.com/keycloakify/keycloakify/assets/6702424/a18ac1ff-dcfd-4b8c-baed-dcda5aa1d762)
```bash ```bash
yarn yarn
yarn storybook 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 ```bash
docker build -f Dockerfile -t keycloakify/keycloakify-starter:main . 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 # 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 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. and remove unnecessary files.
```bash ```bash
cd path/to/keycloakify-starter cd path/to/keycloakify-starter
@ -193,7 +192,7 @@ jobs:
steps: steps:
- uses: garronej/ts-ci@v2.1.0 - uses: garronej/ts-ci@v2.1.0
id: step1 id: step1
with: with:
action_name: is_package_json_version_upgraded action_name: is_package_json_version_upgraded
branch: \${{ github.head_ref || github.ref }} branch: \${{ github.head_ref || github.ref }}
@ -226,4 +225,4 @@ jobs:
EOF 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.

View File

@ -1,15 +1,14 @@
<!doctype html> <!doctype html>
<html lang="en"> <html>
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png"> <link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
</head> </head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html> </html>

View File

@ -1,50 +1,52 @@
{ {
"name": "keycloakify-starter", "name": "keycloakify-starter",
"version": "6.1.10", "version": "6.1.10",
"description": "Starter for Keycloakify 10", "description": "Starter for Keycloakify 10",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "git://github.com/codegouvfr/keycloakify-starter.git" "url": "git://github.com/codegouvfr/keycloakify-starter.git"
}, },
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "tsc && vite build",
"build-keycloak-theme": "yarn build && keycloakify", "build-keycloak-theme": "yarn build && keycloakify",
"storybook": "storybook dev -p 6006", "storybook": "storybook dev -p 6006",
"build-storybook": "storybook build" "build-storybook": "storybook build",
}, "format": "npx prettier . --write"
"license": "MIT", },
"keywords": [], "license": "MIT",
"dependencies": { "keywords": [],
"keycloakify": "10.0.0-rc.31", "dependencies": {
"react": "^18.2.0", "keycloakify": "10.0.0-rc.33",
"react-dom": "^18.2.0" "react": "^18.2.0",
}, "react-dom": "^18.2.0"
"devDependencies": { },
"@storybook/addon-essentials": "^8.0.2", "devDependencies": {
"@storybook/addon-interactions": "^8.0.2", "@storybook/addon-essentials": "^8.0.2",
"@storybook/addon-links": "^8.0.2", "@storybook/addon-interactions": "^8.0.2",
"@storybook/addon-onboarding": "^8.0.2", "@storybook/addon-links": "^8.0.2",
"@storybook/blocks": "^8.0.2", "@storybook/addon-onboarding": "^8.0.2",
"@storybook/react": "^8.0.2", "@storybook/blocks": "^8.0.2",
"@storybook/react-vite": "^8.0.2", "@storybook/react": "^8.0.2",
"@storybook/test": "^8.0.2", "@storybook/react-vite": "^8.0.2",
"@types/react": "^18.2.43", "@storybook/test": "^8.0.2",
"@types/react-dom": "^18.2.17", "@types/react": "^18.2.43",
"@typescript-eslint/eslint-plugin": "^6.14.0", "@types/react-dom": "^18.2.17",
"@typescript-eslint/parser": "^6.14.0", "@typescript-eslint/eslint-plugin": "^6.14.0",
"@vitejs/plugin-react": "^4.2.1", "@typescript-eslint/parser": "^6.14.0",
"eslint": "^8.55.0", "@vitejs/plugin-react": "^4.2.1",
"eslint-plugin-react-hooks": "^4.6.0", "eslint": "^8.55.0",
"eslint-plugin-react-refresh": "^0.4.5", "eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-storybook": "^0.8.0", "eslint-plugin-react-refresh": "^0.4.5",
"storybook": "^8.0.2", "eslint-plugin-storybook": "^0.8.0",
"typescript": "^5.2.2", "prettier": "3.3.1",
"vite": "^5.0.8" "storybook": "^8.0.2",
}, "typescript": "^5.2.2",
"_comment": "See https://github.com/storybookjs/storybook/issues/22431#issuecomment-1630086092", "vite": "^5.0.8"
"resolutions": { },
"jackspeak": "2.1.1" "_comment": "See https://github.com/storybookjs/storybook/issues/22431#issuecomment-1630086092",
} "resolutions": {
"jackspeak": "2.1.1"
}
} }

View File

@ -1,9 +1,11 @@
import { Suspense, lazy } from "react"; 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 { useI18n } from "./i18n";
import Template from "keycloakify/account/Template";
const Fallback = lazy(() => import("keycloakify/account/Fallback")); const Fallback = lazy(() => import("keycloakify/account/Fallback"));
const Template = lazy(() => import("./Template"));
const classes = {} satisfies PageProps["classes"];
export default function KcApp(props: { kcContext: KcContext }) { export default function KcApp(props: { kcContext: KcContext }) {
const { kcContext } = props; const { kcContext } = props;
@ -19,14 +21,17 @@ export default function KcApp(props: { kcContext: KcContext }) {
{(() => { {(() => {
switch (kcContext.pageId) { switch (kcContext.pageId) {
default: default:
return <Fallback return (
{...{ <Fallback
kcContext, {...{
i18n, kcContext,
Template, i18n,
}} classes,
doUseDefaultCss={true} Template
/> }}
doUseDefaultCss={true}
/>
);
} }
})()} })()}
</Suspense> </Suspense>

View File

@ -5,4 +5,7 @@ export type KcContextExtraProperties = {};
export type KcContextExtraPropertiesPerPage = {}; export type KcContextExtraPropertiesPerPage = {};
export type KcContext = ExtendKcContext<KcContextExtraProperties, KcContextExtraPropertiesPerPage>; export type KcContext = ExtendKcContext<
KcContextExtraProperties,
KcContextExtraPropertiesPerPage
>;

View File

@ -1,10 +1,10 @@
import type { DeepPartial } from "keycloakify/tools/DeepPartial"; import type { DeepPartial } from "keycloakify/tools/DeepPartial";
import type { KcContext } from "./kcContext"; import type { KcContext } from "./KcContext";
import { createGetKcContextMock } from "keycloakify/account"; import { createGetKcContextMock } from "keycloakify/account";
import type { import type {
KcContextExtraProperties, KcContextExtraProperties,
KcContextExtraPropertiesPerPage KcContextExtraPropertiesPerPage
} from "./kcContext"; } from "./KcContext";
import KcApp from "./KcApp"; import KcApp from "./KcApp";
const kcContextExtraProperties: KcContextExtraProperties = {}; const kcContextExtraProperties: KcContextExtraProperties = {};
@ -17,10 +17,14 @@ export const { getKcContextMock } = createGetKcContextMock({
overridesPerPage: {} overridesPerPage: {}
}); });
export function createPageStory<PageId extends KcContext["pageId"]>(params: { pageId: PageId }) { export function createPageStory<PageId extends KcContext["pageId"]>(params: {
pageId: PageId;
}) {
const { pageId } = params; 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 { kcContext: overrides } = props;
const kcContextMock = getKcContextMock({ const kcContextMock = getKcContextMock({
@ -28,13 +32,8 @@ export function createPageStory<PageId extends KcContext["pageId"]>(params: { pa
overrides overrides
}); });
return ( return <KcApp kcContext={kcContextMock} />;
<>
<KcApp kcContext={kcContextMock} />
</>
);
} }
return { PageStory }; return { PageStory };
} }

View File

@ -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>
</>
);
}

View File

@ -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 />
};

View File

@ -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" }
}}
/>
)
};

View File

@ -1,11 +1,13 @@
import { Suspense, lazy } from "react"; import { Suspense, lazy } from "react";
import type { PageProps } from "keycloakify/login";
import type { KcContext } from "./KcContext"; import type { KcContext } from "./KcContext";
import { useI18n } from "./i18n"; import { useI18n } from "./i18n";
import { useDownloadTerms } from "keycloakify/login"; import { useDownloadTerms } from "keycloakify/login";
import Template from "keycloakify/login/Template";
const Fallback = lazy(() => import("keycloakify/login/Fallback")); const Fallback = lazy(() => import("keycloakify/login/Fallback"));
const Template = lazy(() => import("./Template")); const UserProfileFormFields = lazy(() => import("keycloakify/login/UserProfileFormFields"));
const UserProfileFormFields = lazy(() => import("./UserProfileFormFields"));
const classes = {} satisfies PageProps["classes"];
export default function KcApp(props: { kcContext: KcContext }) { export default function KcApp(props: { kcContext: KcContext }) {
const { kcContext } = props; const { kcContext } = props;
@ -15,12 +17,14 @@ export default function KcApp(props: { kcContext: KcContext }) {
useDownloadTerms({ useDownloadTerms({
kcContext, kcContext,
downloadTermMarkdown: async ({ currentLanguageTag }) => { downloadTermMarkdown: async ({ currentLanguageTag }) => {
const termsFileName = (() => { const termsFileName = (() => {
switch (currentLanguageTag) { switch (currentLanguageTag) {
case "fr": return "fr.md"; case "fr":
case "es": return "es.md"; return "fr.md";
default: return "en.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}`); const response = await fetch(`${import.meta.env}terms/${termsFileName}`);
return response.text(); return response.text();
} }
}); });
@ -41,15 +44,18 @@ export default function KcApp(props: { kcContext: KcContext }) {
{(() => { {(() => {
switch (kcContext.pageId) { switch (kcContext.pageId) {
default: default:
return <Fallback return (
{...{ <Fallback
kcContext, {...{
i18n, kcContext,
Template, i18n,
UserProfileFormFields classes,
}} Template,
doUseDefaultCss={true} UserProfileFormFields
/> }}
doUseDefaultCss={true}
/>
);
} }
})()} })()}
</Suspense> </Suspense>

View File

@ -5,4 +5,7 @@ export type KcContextExtraProperties = {};
export type KcContextExtraPropertiesPerPage = {}; export type KcContextExtraPropertiesPerPage = {};
export type KcContext = ExtendKcContext<KcContextExtraProperties, KcContextExtraPropertiesPerPage>; export type KcContext = ExtendKcContext<
KcContextExtraProperties,
KcContextExtraPropertiesPerPage
>;

View File

@ -1,11 +1,11 @@
import type { DeepPartial } from "keycloakify/tools/DeepPartial"; import type { DeepPartial } from "keycloakify/tools/DeepPartial";
import type { KcContext } from "./kcContext"; import type { KcContext } from "./KcContext";
import KcApp from "./KcApp"; import KcApp from "./KcApp";
import { createGetKcContextMock } from "keycloakify/login"; import { createGetKcContextMock } from "keycloakify/login";
import type { import type {
KcContextExtraProperties, KcContextExtraProperties,
KcContextExtraPropertiesPerPage KcContextExtraPropertiesPerPage
} from "./kcContext"; } from "./KcContext";
const kcContextExtraProperties: KcContextExtraProperties = {}; const kcContextExtraProperties: KcContextExtraProperties = {};
const kcContextExtraPropertiesPerPage: KcContextExtraPropertiesPerPage = {}; const kcContextExtraPropertiesPerPage: KcContextExtraPropertiesPerPage = {};
@ -17,10 +17,14 @@ export const { getKcContextMock } = createGetKcContextMock({
overridesPerPage: {} overridesPerPage: {}
}); });
export function createPageStory<PageId extends KcContext["pageId"]>(params: { pageId: PageId }) { export function createPageStory<PageId extends KcContext["pageId"]>(params: {
pageId: PageId;
}) {
const { pageId } = params; 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 { kcContext: overrides } = props;
const kcContextMock = getKcContextMock({ const kcContextMock = getKcContextMock({
@ -37,4 +41,3 @@ export function createPageStory<PageId extends KcContext["pageId"]>(params: { pa
return { PageStory }; return { PageStory };
} }

View File

@ -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>
);
}

View File

@ -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 ? <>&nbsp;|&nbsp;</> : 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>
);
}

View File

@ -1,4 +1,3 @@
import type { Meta, StoryObj } from "@storybook/react"; import type { Meta, StoryObj } from "@storybook/react";
import { createPageStory } from "../PageStory"; import { createPageStory } from "../PageStory";

View File

@ -1,24 +1,33 @@
/* eslint-disable react-refresh/only-export-components */ /* eslint-disable react-refresh/only-export-components */
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { StrictMode, lazy, Suspense } from "react"; 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 KcLoginThemeApp = lazy(() => import("./login/KcApp"));
const KcAccountThemeApp = lazy(() => import("./account/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( createRoot(document.getElementById("root")!).render(
<StrictMode> <StrictMode>
<Suspense> <Suspense>
{(() => { {(() => {
switch (kcContext?.themeType) { switch (kcContext?.themeType) {
case "login": return <KcLoginThemeApp kcContext={kcContext} />; case "login":
case "account": return <KcAccountThemeApp kcContext={kcContext} />; return <KcLoginThemeApp kcContext={kcContext} />;
case undefined: return <h1>No Keycloak Context</h1>; case "account":
return <KcAccountThemeApp kcContext={kcContext} />;
case undefined:
return <h1>No Keycloak Context</h1>;
} }
})()} })()}
</Suspense> </Suspense>
</StrictMode> </StrictMode>
); );

9
src/vite-env.d.ts vendored
View File

@ -3,9 +3,8 @@
import type { KcContext as KcContextLogin } from "./login/kcContext"; import type { KcContext as KcContextLogin } from "./login/kcContext";
import type { KcContext as KcContextAccount } from "./account/kcContext"; import type { KcContext as KcContextAccount } from "./account/kcContext";
declare global { declare global {
interface Window { interface Window {
kcContext?: KcContextLogin | KcContextAccount; kcContext?: KcContextLogin | KcContextAccount;
} }
} }

View File

@ -1,25 +1,25 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2020", "target": "ES2020",
"useDefineForClassFields": true, "useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"], "lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext", "module": "ESNext",
"skipLibCheck": true, "skipLibCheck": true,
/* Bundler mode */ /* Bundler mode */
"moduleResolution": "bundler", "moduleResolution": "bundler",
"allowImportingTsExtensions": true, "allowImportingTsExtensions": true,
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
"noEmit": true, "noEmit": true,
"jsx": "react-jsx", "jsx": "react-jsx",
/* Linting */ /* Linting */
"strict": true, "strict": true,
"noUnusedLocals": true, "noUnusedLocals": true,
"noUnusedParameters": true, "noUnusedParameters": true,
"noFallthroughCasesInSwitch": true "noFallthroughCasesInSwitch": true
}, },
"include": ["src"], "include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }] "references": [{ "path": "./tsconfig.node.json" }]
} }

View File

@ -1,10 +1,10 @@
{ {
"compilerOptions": { "compilerOptions": {
"composite": true, "composite": true,
"skipLibCheck": true, "skipLibCheck": true,
"module": "ESNext", "module": "ESNext",
"moduleResolution": "bundler", "moduleResolution": "bundler",
"allowSyntheticDefaultImports": true "allowSyntheticDefaultImports": true
}, },
"include": ["vite.config.ts"] "include": ["vite.config.ts"]
} }

View File

@ -1,15 +1,11 @@
import { defineConfig } from 'vite' import { defineConfig } from "vite";
import react from '@vitejs/plugin-react' import react from "@vitejs/plugin-react";
import { keycloakify } from "keycloakify/vite-plugin"; import { keycloakify } from "keycloakify/vite-plugin";
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [react(), keycloakify()],
react(), build: {
keycloakify() sourcemap: true
], }
build: { });
sourcemap: true
}
})

View File

@ -5011,10 +5011,10 @@ jsonfile@^6.0.1:
optionalDependencies: optionalDependencies:
graceful-fs "^4.1.6" graceful-fs "^4.1.6"
keycloakify@10.0.0-rc.31: keycloakify@10.0.0-rc.33:
version "10.0.0-rc.31" version "10.0.0-rc.33"
resolved "https://registry.yarnpkg.com/keycloakify/-/keycloakify-10.0.0-rc.31.tgz#4ccd4887de0f759ff91f5765a9011c77fbc2230f" resolved "https://registry.yarnpkg.com/keycloakify/-/keycloakify-10.0.0-rc.33.tgz#2a522facaf3138e7c9b699e95ef45cfc73ab0296"
integrity sha512-UMDtVq4jxlihKPnp2OMo2FXTlAEl0PpdN8Bbk0yBxvxgvPuDXazWM2smi4tr48aTLGhx/fWdiyw1mvsOlcFvPA== integrity sha512-rByUFHqsSQ1P9ZsnbCtB02rHfF38J4+dV0gr/oArAviLt6NauO2r3KoRKMtkeT/1OKCvkthvK7cloFWEjBDiBQ==
dependencies: dependencies:
react-markdown "^5.0.3" react-markdown "^5.0.3"
tsafe "^1.6.6" 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" resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396"
integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g== integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==
prettier@3.3.1:
version "3.3.1"
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.3.1.tgz#e68935518dd90bb7ec4821ba970e68f8de16e1ac"
integrity sha512-7CAwy5dRsxs8PHXT3twixW9/OEll8MLE0VRPCJyl7CkS6VHGPSlsVaWTiASPTyGyYRyApxlaWTzwUxVNrhcwDg==
prettier@^3.1.1: prettier@^3.1.1:
version "3.2.5" version "3.2.5"
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.2.5.tgz#e52bc3090586e824964a8813b09aba6233b28368" resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.2.5.tgz#e52bc3090586e824964a8813b09aba6233b28368"