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/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 }} + 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/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 }} 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 @@ + 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; diff --git a/README.md b/README.md index 963178d..e1015ae 100644 --- a/README.md +++ b/README.md @@ -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. -![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. -[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**: `/:latest` and `/:X.Y.Z` [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) +# 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 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/ts-ci@v2.1.0 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. diff --git a/index.html b/index.html index 6791f43..089aa15 100644 --- a/index.html +++ b/index.html @@ -1,15 +1,14 @@ - - - - + + + + - - - - -
- - + + + +
+ + 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 + return ( + + ); } })()} 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; +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(params: { pageId: PageId }) { +export function createPageStory(params: { + pageId: PageId; +}) { const { pageId } = params; - function PageStory(props: { kcContext?: DeepPartial> }) { + function PageStory(props: { + kcContext?: DeepPartial>; + }) { const { kcContext: overrides } = props; const kcContextMock = getKcContextMock({ @@ -28,13 +32,8 @@ export function createPageStory(params: { pa overrides }); - return ( - <> - - - ); + return ; } 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) { - 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 ( - <> -
- -
- -
-
- -
- -
- {message !== undefined && ( -
- {message.type === "success" && } - {message.type === "error" && } - {message.summary} -
- )} - - {children} -
-
- - ); -} 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; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + render: () => +}; 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; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + render: () => +}; + +export const WithMessage: Story = { + render: () => ( + + ) +}; 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 + return ( + + ); } })()} 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; +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(params: { pageId: PageId }) { +export function createPageStory(params: { + pageId: PageId; +}) { const { pageId } = params; - function PageStory(props: { kcContext?: DeepPartial> }) { + function PageStory(props: { + kcContext?: DeepPartial>; + }) { const { kcContext: overrides } = props; const kcContextMock = getKcContextMock({ @@ -37,4 +41,3 @@ export function createPageStory(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) { - 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 ( -
-
-
- {msg("loginTitleHtml", realm.displayNameHtml)} -
-
- -
-
- {realm.internationalizationEnabled && (assert(locale !== undefined), locale.supported.length > 1) && ( -
-
-
- - -
-
-
- )} - {!(auth !== undefined && auth.showUsername && !auth.showResetCredentials) ? ( - displayRequiredFields ? ( -
-
- - * - {msg("requiredFields")} - -
-
-

{headerNode}

-
-
- ) : ( -

{headerNode}

- ) - ) : displayRequiredFields ? ( -
-
- - * {msg("requiredFields")} - -
-
- {showUsernameNode} -
- - -
- - {msg("restartLoginTooltip")} -
-
-
-
-
- ) : ( - <> - {showUsernameNode} -
- - -
- - {msg("restartLoginTooltip")} -
-
-
- - )} -
-
-
- {/* App-initiated actions should not see warning messages about the need to complete the action during login. */} - {displayMessage && message !== undefined && (message.type !== "warning" || !isAppInitiatedAction) && ( -
-
- {message.type === "success" && } - {message.type === "warning" && } - {message.type === "error" && } - {message.type === "info" && } -
- -
- )} - {children} - {auth !== undefined && auth.showTryAnotherWayLink && ( -
- -
- )} - {socialProvidersNode} - {displayInfo && ( -
-
- {infoNode} -
-
- )} -
-
-
-
- ); -} 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; - 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 ( - - - {BeforeField !== undefined && ( - - )} -
-
- - {attribute.required && <>*} -
-
- {attribute.annotations.inputHelperTextBefore !== undefined && ( -
- {advancedMsg(attribute.annotations.inputHelperTextBefore)} -
- )} - - - {attribute.annotations.inputHelperTextAfter !== undefined && ( -
- {advancedMsg(attribute.annotations.inputHelperTextAfter)} -
- )} - - {AfterField !== undefined && ( - - )} - {/* NOTE: Downloading of html5DataAnnotations scripts is done in the useUserProfileForm hook */} -
-
-
- ); - })} - - ); -} - -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 ( -
[`data-${key}`, value]))} - > - {(() => { - const groupDisplayHeader = attribute.group.displayHeader ?? ""; - const groupHeaderText = groupDisplayHeader !== "" ? advancedMsg(groupDisplayHeader) : attribute.group.name; - - return ( -
- -
- ); - })()} - {(() => { - const groupDisplayDescription = attribute.group.displayDescription ?? ""; - - if (groupDisplayDescription !== "") { - const groupDescriptionText = advancedMsg(groupDisplayDescription); - - return ( -
- -
- ); - } - - return null; - })()} -
- ); - } - } - - 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 ( - - {displayableErrors - .filter(error => error.fieldIndex === fieldIndex) - .map(({ errorMessage }, i, arr) => ( - - {errorMessage} - {arr.length - 1 !== i &&
} -
- ))} -
- ); -} - -type InputFiledByTypeProps = { - attribute: Attribute; - valueOrValues: string | string[]; - displayableErrors: FormFieldError[]; - formValidationDispatch: React.Dispatch; - getClassName: UserProfileFormFieldsProps["getClassName"]; - i18n: I18n; -}; - -function InputFiledByType(props: InputFiledByTypeProps) { - const { attribute, valueOrValues } = props; - - switch (attribute.annotations.inputType) { - case "textarea": - return ; - case "select": - case "multiselect": - return ; - case "select-radiobuttons": - case "multiselect-checkboxes": - return ; - default: { - if (valueOrValues instanceof Array) { - return ( - <> - {valueOrValues.map((...[, i]) => ( - - ))} - - ); - } - - const inputNode = ; - - if (attribute.name === "password" || attribute.name === "password-confirm") { - return ( - - {inputNode} - - ); - } - - 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 ( -
- {children} - -
- ); -} - -function InputTag(props: InputFiledByTypeProps & { fieldIndex: number | undefined }) { - const { attribute, fieldIndex, getClassName, formValidationDispatch, valueOrValues, i18n, displayableErrors } = props; - - return ( - <> - { - 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 ( - <> - - - - ); - })()} - - ); -} - -function AddRemoveButtonsMultiValuedAttribute(props: { - attribute: Attribute; - values: string[]; - fieldIndex: number; - dispatchFormAction: React.Dispatch>; - 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 && ( - <> - - {hasAdd ? <> |  : null} - - )} - {hasAdd && ( - - )} - - ); -} - -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)[inputOptionsFromValidation]; - - if (validator === undefined) { - break walk; - } - - if (validator.options === undefined) { - break walk; - } - - return validator.options; - } - - return attribute.validators.options?.options ?? []; - })(); - - return ( - <> - {options.map(option => ( -
- - 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 - }) - } - /> - -
- ))} - - ); -} - -function TextareaTag(props: InputFiledByTypeProps) { - const { attribute, formValidationDispatch, getClassName, displayableErrors, valueOrValues } = props; - - assert(typeof valueOrValues === "string"); - - const value = valueOrValues; - - return ( -