diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index 9825c9c..0000000 --- a/.dockerignore +++ /dev/null @@ -1,30 +0,0 @@ -# Logs -logs -*.log - -# Runtime data -pids -*.pid -*.seed - -# Directory for instrumented libs generated by jscoverage/JSCover -lib-cov - -# Coverage directory used by tools like istanbul -coverage - -# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) -.grunt - -# node-waf configuration -.lock-wscript - -# Compiled binary addons (http://nodejs.org/api/addons.html) -build/Release - -/Dockerfile -/node_modules -/.github -/.vscode -/docs -/build \ No newline at end of file diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 11a205f..7f51c0f 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -28,7 +28,7 @@ jobs: to_version: ${{ steps.step1.outputs.to_version }} is_upgraded_version: ${{ steps.step1.outputs.is_upgraded_version }} steps: - - uses: garronej/ts-ci@v2.1.0 + - uses: garronej/ts-ci@v2.1.2 id: step1 with: action_name: is_package_json_version_upgraded @@ -60,50 +60,3 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - docker: - runs-on: ubuntu-latest - needs: - - check_if_version_upgraded - - create_github_release - steps: - - uses: actions/checkout@v2 - - uses: docker/setup-qemu-action@v1 - - uses: docker/setup-buildx-action@v1 - - uses: docker/login-action@v1 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Computing Docker image tags - id: step1 - env: - IS_UPGRADED_VERSION: ${{ needs.check_if_version_upgraded.outputs.is_upgraded_version }} - TO_VERSION: ${{ needs.check_if_version_upgraded.outputs.to_version }} - run: | - OUT=$GITHUB_REPOSITORY:$TO_VERSION,$GITHUB_REPOSITORY:latest - OUT=$(echo "$OUT" | awk '{print tolower($0)}') - echo ::set-output name=docker_tags::$OUT - - uses: docker/build-push-action@v2 - with: - push: true - context: . - tags: ${{ steps.step1.outputs.docker_tags }} - - github_pages: - runs-on: ubuntu-latest - needs: - - create_github_release - steps: - - uses: actions/checkout@v2 - - uses: actions/setup-node@v2 - - uses: bahmutov/npm-install@v1 - - run: yarn build - # We tell GitHub pages that our package.json["homepage"] field is our domain name. - # If you wish to use the default GitHub pages domain name, like https://.github.io/, - # you'll have to use base: "/repo/" in your vite.config.ts. - - run: echo $(node -e 'console.log(require("url").parse(require("./package.json").homepage).host)') > dist/CNAME - - run: git remote set-url origin https://git:${GITHUB_TOKEN}@github.com/${{github.repository}}.git - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - uses: actions/setup-node@v3.6.0 - - run: npx -y -p gh-pages@3.0.0 gh-pages -u "github-actions-bot " -d dist - diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 7056216..0000000 --- a/Dockerfile +++ /dev/null @@ -1,13 +0,0 @@ -# build environment -FROM node:18-alpine as build -WORKDIR /app -COPY package.json yarn.lock ./ -RUN yarn install --frozen-lockfile -COPY . . -RUN yarn build - -# production environment -FROM nginx:stable-alpine -COPY --from=build /app/dist /usr/share/nginx/html -COPY --from=build /app/nginx.conf /etc/nginx/conf.d/default.conf -CMD nginx -g 'daemon off;' \ No newline at end of file diff --git a/index.html b/index.html index 2c5a65b..6791f43 100644 --- a/index.html +++ b/index.html @@ -4,70 +4,7 @@ - - - - Keycloakify starter - - - - - - - - - - - - + diff --git a/nginx.conf b/nginx.conf deleted file mode 100644 index 50b4293..0000000 --- a/nginx.conf +++ /dev/null @@ -1,35 +0,0 @@ -server { - listen 80; - - gzip on; - gzip_vary on; - gzip_min_length 1024; - gzip_proxied expired no-cache no-store private auth; - gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/javascript application/xml; - gzip_disable "MSIE [1-6]\."; - - root /usr/share/nginx/html; - index index.html; - - try_files $uri $uri/ /index.html; - - # Any route containing a file extension (e.g. /devicesfile.js) - location ~ ^.+\..+$ { - try_files $uri =404; - - location ~* \.(?:html|json|txt)$ { - expires -1; - } - - # Vite generates filenames with hashes so we can - # tell the browser to keep in cache the resources. - location ~* \.(?:css|js|md|woff2?|eot|ttf|xml)$ { - expires 1y; - access_log off; - add_header Cache-Control "public"; - - } - - } - -} diff --git a/package.json b/package.json index 673be66..2cd6edb 100755 --- a/package.json +++ b/package.json @@ -1,8 +1,7 @@ { "name": "keycloakify-starter", - "homepage": "https://starter.keycloakify.dev", "version": "6.1.10", - "description": "A starter/demo project for keycloakify", + "description": "Starter for Keycloakify 10", "repository": { "type": "git", "url": "git://github.com/codegouvfr/keycloakify-starter.git" @@ -15,18 +14,12 @@ "storybook": "storybook dev -p 6006", "build-storybook": "storybook build" }, - "author": "u/garronej", "license": "MIT", "keywords": [], "dependencies": { - "evt": "^2.5.7", - "keycloakify": "^9.6.6", - "oidc-spa": "^4.6.2", - "powerhooks": "^1.0.8", + "keycloakify": "10.0.0-rc.31", "react": "^18.2.0", - "react-dom": "^18.2.0", - "tsafe": "^1.6.6", - "zod": "^3.22.4" + "react-dom": "^18.2.0" }, "devDependencies": { "@storybook/addon-essentials": "^8.0.2", @@ -48,8 +41,7 @@ "eslint-plugin-storybook": "^0.8.0", "storybook": "^8.0.2", "typescript": "^5.2.2", - "vite": "^5.0.8", - "vite-plugin-commonjs": "^0.10.1" + "vite": "^5.0.8" }, "_comment": "See https://github.com/storybookjs/storybook/issues/22431#issuecomment-1630086092", "resolutions": { diff --git a/public/fonts/WorkSans/font.css b/public/fonts/WorkSans/font.css deleted file mode 100644 index 29f5e14..0000000 --- a/public/fonts/WorkSans/font.css +++ /dev/null @@ -1,36 +0,0 @@ - -/* -This file is only meant to be used by Storybook -*/ - -@font-face { - font-family: "Work Sans"; - font-style: normal; - font-weight: normal; /*400*/ - font-display: swap; - src: url("./worksans-regular-webfont.woff2") format("woff2"); - } - - @font-face { - font-family: "Work Sans"; - font-style: normal; - font-weight: 500; - font-display: swap; - src: url("./worksans-medium-webfont.woff2") format("woff2"); - } - - @font-face { - font-family: "Work Sans"; - font-style: normal; - font-weight: 600; - font-display: swap; - src: url("./worksans-semibold-webfont.woff2") format("woff2"); - } - - @font-face { - font-family: "Work Sans"; - font-style: normal; - font-weight: bold; /*700*/ - font-display: swap; - src: url("./worksans-bold-webfont.woff2") format("woff2"); - } \ No newline at end of file diff --git a/public/fonts/WorkSans/worksans-bold-webfont.woff2 b/public/fonts/WorkSans/worksans-bold-webfont.woff2 deleted file mode 100644 index 8705354..0000000 Binary files a/public/fonts/WorkSans/worksans-bold-webfont.woff2 and /dev/null differ diff --git a/public/fonts/WorkSans/worksans-medium-webfont.woff2 b/public/fonts/WorkSans/worksans-medium-webfont.woff2 deleted file mode 100644 index 8705354..0000000 Binary files a/public/fonts/WorkSans/worksans-medium-webfont.woff2 and /dev/null differ diff --git a/public/fonts/WorkSans/worksans-regular-webfont.woff2 b/public/fonts/WorkSans/worksans-regular-webfont.woff2 deleted file mode 100644 index 8705354..0000000 Binary files a/public/fonts/WorkSans/worksans-regular-webfont.woff2 and /dev/null differ diff --git a/public/fonts/WorkSans/worksans-semibold-webfont.woff2 b/public/fonts/WorkSans/worksans-semibold-webfont.woff2 deleted file mode 100644 index 8705354..0000000 Binary files a/public/fonts/WorkSans/worksans-semibold-webfont.woff2 and /dev/null differ diff --git a/public/keycloakify-logo.png b/public/keycloakify-logo.png deleted file mode 100644 index b9d103c..0000000 Binary files a/public/keycloakify-logo.png and /dev/null differ diff --git a/public/silent-sso.html b/public/silent-sso.html deleted file mode 100644 index 2bf0c57..0000000 --- a/public/silent-sso.html +++ /dev/null @@ -1,7 +0,0 @@ - - - - - diff --git a/src/App/App.css b/src/App/App.css deleted file mode 100644 index 841ff5b..0000000 --- a/src/App/App.css +++ /dev/null @@ -1,58 +0,0 @@ -body { - margin: 0; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', - 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', - sans-serif; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - background-color: #242424; -} - - -.App { - display: flex; - justify-content: center; - align-items: center; - height: 100vh; -} - -.App-payload { - text-align: center; - margin-bottom: 4rem; - color: white; - /* link color */ - a { - color: #61dafb; - } - -} - -.App-logo-wrapper { - text-align: center; -} - -.App-logo { - height: 15vmin; - pointer-events: none; -} - -@media (prefers-reduced-motion: no-preference) { - .App-logo.rotate { - animation: App-logo-spin infinite 20s linear; - } -} - -.App-link { - color: #61dafb; -} - -@keyframes App-logo-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} - - diff --git a/src/App/App.tsx b/src/App/App.tsx deleted file mode 100644 index 521960d..0000000 --- a/src/App/App.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import "./App.css"; -import reactSvgUrl from "./assets/react.svg"; -import viteSvgUrl from "./assets/vite.svg"; -import { OidcProvider, useOidc, getKeycloakAccountUrl } from "./oidc"; - -export default function App() { - return ( - // To integrate Keycloak to your React App you have many options such as: - // - https://www.npmjs.com/package/keycloak-js - // - https://github.com/authts/oidc-client-ts - // - https://github.com/authts/react-oidc-context - // In this starter we use oidc-spa instead - // It's a new library made by us, the Keycloakify team. - // Check it out: https://github.com/keycloakify/oidc-spa - - - - ); -} - -function ContextualizedApp() { - - const { isUserLoggedIn, login, logout, oidcTokens } = useOidc(); - - return ( -
-
-
- {isUserLoggedIn ? - ( - <> - -

Hello {oidcTokens.decodedIdToken.name} !

- - Link to your Keycloak account - -     - - - - ) - : - ( - - ) - } -
-
- logo -     - logo -
-
-
- ); - -} - -function Jwt() { - - const { oidcTokens } = useOidc({ - assertUserLoggedIn: true - }); - - // NOTE: Use `Bearer ${oidcTokens.accessToken}` as the Authorization header to call your backend - // Here we just display the decoded id token - - return ( -
-            {JSON.stringify(oidcTokens.decodedIdToken, null, 2)}
-        
- ); - -} - diff --git a/src/App/assets/react.svg b/src/App/assets/react.svg deleted file mode 100644 index 6c87de9..0000000 --- a/src/App/assets/react.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/App/assets/vite.svg b/src/App/assets/vite.svg deleted file mode 100644 index e7b8dfb..0000000 --- a/src/App/assets/vite.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/App/index.ts b/src/App/index.ts deleted file mode 100644 index 7964e4b..0000000 --- a/src/App/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -import App from "./App"; -export * from "./App"; - -export default App; diff --git a/src/App/oidc.ts b/src/App/oidc.ts deleted file mode 100644 index fb59b45..0000000 --- a/src/App/oidc.ts +++ /dev/null @@ -1,60 +0,0 @@ -// See documentation of oidc-spa for more details: -// https://docs.oidc-spa.dev - -import { createReactOidc } from "oidc-spa/react"; -import { z } from "zod"; - -//On older Keycloak version you need the /auth (e.g: http://localhost:8080/auth) -//On newer version you must remove it (e.g: http://localhost:8080 ) -const keycloakUrl = "https://cloud-iam.keycloakify.dev/"; -const keycloakRealm = "keycloakify"; -const keycloakClientId= "starter"; - -export const { OidcProvider, useOidc } = createReactOidc({ - issuerUri: `${keycloakUrl}/realms/${keycloakRealm}`, - clientId: keycloakClientId, - // NOTE: You can also pass queries params when calling login() - extraQueryParams: () => ({ - // This adding ui_locales to the url will ensure the consistency of the language between the app and the login pages - // If your app implements a i18n system (like i18nifty.dev for example) you should use this and replace "en" by the - // current language of the app. - // On the other side you will find kcContext.locale.currentLanguageTag to be whatever you set here. - "ui_locales": "en", - "my_custom_param": "value of foo transferred to login page" - }), - publicUrl: import.meta.env.BASE_URL, - decodedIdTokenSchema: z.object({ - // Use https://jwt.io/ to tell what's in your idToken - // It will depend of your Keycloak configuration. - // Here I declare only two field on the type but actually there are - // Many more things available. - sub: z.string(), - name: z.string(), - preferred_username: z.string(), - // This is a custom attribute set up in our Keycloak configuration - // it's not present by default. - // See https://docs.keycloakify.dev/realtime-input-validation#getting-your-custom-user-attribute-to-be-included-in-the-jwt - favorite_pet: z.union([z.literal("cat"), z.literal("dog"), z.literal("bird")]) - }) -}); - - -export function getKeycloakAccountUrl( - params: { - locale: string; - } -){ - const { locale } = params; - - const accountUrl = new URL(`${keycloakUrl}/realms/${keycloakRealm}/account`); - - const searchParams = new URLSearchParams(); - - searchParams.append("kc_locale", locale); - searchParams.append("referrer", keycloakClientId); - searchParams.append("referrer_uri", window.location.href); - - accountUrl.search = searchParams.toString(); - - return accountUrl.toString(); -} \ No newline at end of file diff --git a/src/account/KcApp.tsx b/src/account/KcApp.tsx new file mode 100644 index 0000000..7920bba --- /dev/null +++ b/src/account/KcApp.tsx @@ -0,0 +1,34 @@ +import { Suspense, lazy } from "react"; +import type { KcContext } from "./kcContext"; +import { useI18n } from "./i18n"; + +const Fallback = lazy(() => import("keycloakify/account/Fallback")); +const Template = lazy(() => import("./Template")); + +export default function KcApp(props: { kcContext: KcContext }) { + const { kcContext } = props; + + const i18n = useI18n({ kcContext }); + + if (i18n === null) { + return null; + } + + return ( + + {(() => { + switch (kcContext.pageId) { + default: + return + } + })()} + + ); +} diff --git a/src/account/KcContext.ts b/src/account/KcContext.ts new file mode 100644 index 0000000..985ea03 --- /dev/null +++ b/src/account/KcContext.ts @@ -0,0 +1,8 @@ +/* eslint-disable @typescript-eslint/ban-types */ +import type { ExtendKcContext } from "keycloakify/account"; + +export type KcContextExtraProperties = {}; + +export type KcContextExtraPropertiesPerPage = {}; + +export type KcContext = ExtendKcContext; diff --git a/src/account/PageStory.tsx b/src/account/PageStory.tsx new file mode 100644 index 0000000..e249d58 --- /dev/null +++ b/src/account/PageStory.tsx @@ -0,0 +1,40 @@ +import type { DeepPartial } from "keycloakify/tools/DeepPartial"; +import type { KcContext } from "./kcContext"; +import { createGetKcContextMock } from "keycloakify/account"; +import type { + KcContextExtraProperties, + KcContextExtraPropertiesPerPage +} from "./kcContext"; +import KcApp from "./KcApp"; + +const kcContextExtraProperties: KcContextExtraProperties = {}; +const kcContextExtraPropertiesPerPage: KcContextExtraPropertiesPerPage = {}; + +export const { getKcContextMock } = createGetKcContextMock({ + kcContextExtraProperties, + kcContextExtraPropertiesPerPage, + overrides: {}, + overridesPerPage: {} +}); + +export function createPageStory(params: { pageId: PageId }) { + const { pageId } = params; + + function PageStory(props: { kcContext?: DeepPartial> }) { + const { kcContext: overrides } = props; + + const kcContextMock = getKcContextMock({ + pageId, + overrides + }); + + return ( + <> + + + ); + } + + return { PageStory }; +} + diff --git a/src/keycloak-theme/account/Template.tsx b/src/account/Template.tsx similarity index 77% rename from src/keycloak-theme/account/Template.tsx rename to src/account/Template.tsx index 93e4e2f..9e6cd0d 100644 --- a/src/keycloak-theme/account/Template.tsx +++ b/src/account/Template.tsx @@ -1,36 +1,62 @@ -// Copy pasted from: https://github.com/InseeFrLab/keycloakify/blob/main/src/login/Template.tsx +// Copy pasted from: https://github.com/keycloakify/keycloakify/blob/main/src/account/Template.tsx -import { clsx } from "keycloakify/tools/clsx"; -import { usePrepareTemplate } from "keycloakify/lib/usePrepareTemplate"; -import { type TemplateProps } from "keycloakify/account/TemplateProps"; -import { useGetClassName } from "keycloakify/account/lib/useGetClassName"; -import type { KcContext } from "./kcContext"; -import type { I18n } from "./i18n"; +import { useEffect } from "react"; import { assert } from "keycloakify/tools/assert"; +import { clsx } from "keycloakify/tools/clsx"; +import { 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, changeLocale, labelBySupportedLanguageTag, currentLanguageTag } = i18n; + const { msg, msgStr, getChangeLocalUrl, labelBySupportedLanguageTag, currentLanguageTag } = i18n; const { locale, url, features, realm, message, referrer } = kcContext; - const { isReady } = usePrepareTemplate({ - "doFetchDefaultThemeResources": doUseDefaultCss, - "styles": [ - `${url.resourcesCommonPath}/node_modules/patternfly/dist/css/patternfly.min.css`, - `${url.resourcesCommonPath}/node_modules/patternfly/dist/css/patternfly-additions.min.css`, - `${url.resourcesPath}/css/account.css` - ], - "htmlClassName": getClassName("kcHtmlClass"), - "bodyClassName": clsx("admin-console", "user", getClassName("kcBodyClass")), - "htmlLangProperty": locale?.currentLanguageTag, - "documentTitle": i18n.msgStr("accountManagementTitle") + useEffect(() => { + document.title = msgStr("accountManagementTitle"); + }, []); + + useSetClassName({ + qualifiedName: "html", + className: getClassName("kcHtmlClass") }); - if (!isReady) { + 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; } @@ -55,9 +81,7 @@ export default function Template(props: TemplateProps) { diff --git a/src/account/i18n.ts b/src/account/i18n.ts new file mode 100644 index 0000000..c4ad70c --- /dev/null +++ b/src/account/i18n.ts @@ -0,0 +1,5 @@ +import { createUseI18n } from "keycloakify/account"; + +export const { useI18n, ofTypeI18n } = createUseI18n({}); + +export type I18n = typeof ofTypeI18n; diff --git a/src/keycloak-theme/README.md b/src/keycloak-theme/README.md deleted file mode 100644 index 0920d0b..0000000 --- a/src/keycloak-theme/README.md +++ /dev/null @@ -1,29 +0,0 @@ -Your theme source files should be located in a keycloak-theme directory somewhere in your src directory OR at the root of your directory. -Acceptable directory strucuture: - -```txt -src/ - keycloak-theme/ - login/ - account/ - email/ - -===OR=== - -src/ - foo/ - bar/ - keycloak-theme/ - login/ - account/ - email/ - -===OR=== - -src/ - login/ - account/ - email/ -``` - -You don't need to have all three variant of the theme. If you only need the login theme for example you can have only the login directory. diff --git a/src/keycloak-theme/account/KcApp.css b/src/keycloak-theme/account/KcApp.css deleted file mode 100644 index 860849b..0000000 --- a/src/keycloak-theme/account/KcApp.css +++ /dev/null @@ -1,9 +0,0 @@ -/* -If you use global CSS like we do here(not recommended) -Be mindful that the CSS of the login theme may clash with the CSS of the account theme in Storybook (and only in storybook). -This is why I made sure to use .my-root-account-class instead of .my-root-class that is already used in the login theme. -*/ - -.my-root-account-class { - background: url(./assets/background.svg) no-repeat center center fixed; -} \ No newline at end of file diff --git a/src/keycloak-theme/account/KcApp.tsx b/src/keycloak-theme/account/KcApp.tsx deleted file mode 100644 index 3f88a88..0000000 --- a/src/keycloak-theme/account/KcApp.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import "./KcApp.css"; -import { lazy, Suspense } from "react"; -import type { PageProps } from "keycloakify/account"; -import type { KcContext } from "./kcContext"; -import { useI18n } from "./i18n"; -import Template from "./Template"; - -const Password = lazy(() => import("./pages/Password")); -const MyExtraPage1 = lazy(() => import("./pages/MyExtraPage1")); -const MyExtraPage2 = lazy(() => import("./pages/MyExtraPage2")); -const Fallback = lazy(()=> import("keycloakify/account")); - -const classes = { - "kcBodyClass": "my-root-account-class" -} satisfies PageProps["classes"]; - -export default function KcApp(props: { kcContext: KcContext; }) { - - const { kcContext } = props; - - const i18n = useI18n({ kcContext }); - - if (i18n === null) { - return null; - } - - return ( - - {(() => { - switch (kcContext.pageId) { - case "password.ftl": return ; - case "my-extra-page-1.ftl": return ; - case "my-extra-page-2.ftl": return ; - default: return ; - } - })()} - - ); - -} diff --git a/src/keycloak-theme/account/assets/background.svg b/src/keycloak-theme/account/assets/background.svg deleted file mode 100644 index 0e1cada..0000000 --- a/src/keycloak-theme/account/assets/background.svg +++ /dev/null @@ -1,132 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/keycloak-theme/account/createPageStory.tsx b/src/keycloak-theme/account/createPageStory.tsx deleted file mode 100644 index cecb034..0000000 --- a/src/keycloak-theme/account/createPageStory.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { getKcContext, type KcContext } from "./kcContext"; -import KcApp from "./KcApp"; -import type { DeepPartial } from "keycloakify/tools/DeepPartial"; - -export function createPageStory(params: { - pageId: PageId; -}) { - - const { pageId } = params; - - function PageStory(params: { kcContext?: DeepPartial>; }) { - - const { kcContext } = getKcContext({ - mockPageId: pageId, - storyPartialKcContext: params.kcContext - }); - - return ( - <> - {/* If you import custom fonts in your index.html you have to import them in storybook as well*/} - - - - ); - - } - - return { PageStory }; - -} diff --git a/src/keycloak-theme/account/i18n.ts b/src/keycloak-theme/account/i18n.ts deleted file mode 100644 index 45f75c4..0000000 --- a/src/keycloak-theme/account/i18n.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { createUseI18n } from "keycloakify/account"; - -//NOTE: See src/login/i18n.ts for instructions on customization of i18n messages. -export const { useI18n } = createUseI18n({}); - -export type I18n = NonNullable>; diff --git a/src/keycloak-theme/account/kcContext.ts b/src/keycloak-theme/account/kcContext.ts deleted file mode 100644 index 3a5d268..0000000 --- a/src/keycloak-theme/account/kcContext.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { createGetKcContext } from "keycloakify/account"; - -export type KcContextExtension = - | { pageId: "my-extra-page-1.ftl"; } - | { pageId: "my-extra-page-2.ftl"; someCustomValue: string; }; - -export const { getKcContext } = createGetKcContext({ - mockData: [ - { - pageId: "my-extra-page-2.ftl", - someCustomValue: "foo bar" - } - ], - mockProperties: { - MY_ENV_VARIABLE: "Mocked value" - } -}); - -export const { kcContext } = getKcContext({ - //mockPageId: "password.ftl", -}); - -export type KcContext = NonNullable["kcContext"]>; diff --git a/src/keycloak-theme/account/pages/MyExtraPage1.tsx b/src/keycloak-theme/account/pages/MyExtraPage1.tsx deleted file mode 100644 index 649e4cb..0000000 --- a/src/keycloak-theme/account/pages/MyExtraPage1.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import type { PageProps } from "keycloakify/account/pages/PageProps"; -import type { KcContext } from "../kcContext"; -import type { I18n } from "../i18n"; - -export default function MyExtraPage1(props: PageProps, I18n>) { - - const { kcContext, i18n, doUseDefaultCss, Template, classes } = props; - - return ( - - ); - -} diff --git a/src/keycloak-theme/account/pages/MyExtraPage2.tsx b/src/keycloak-theme/account/pages/MyExtraPage2.tsx deleted file mode 100644 index dc90e84..0000000 --- a/src/keycloak-theme/account/pages/MyExtraPage2.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import type { PageProps } from "keycloakify/account/pages/PageProps"; -import type { KcContext } from "../kcContext"; -import type { I18n } from "../i18n"; - -export default function MyExtraPage1(props: PageProps, I18n>) { - - const { kcContext, i18n, doUseDefaultCss, Template, classes } = props; - - // someCustomValue is declared by you in ../kcContext.ts - console.log(`TODO: Do something with: ${kcContext.someCustomValue}`); - - return ( - - ); - -} diff --git a/src/keycloak-theme/account/pages/Password.stories.tsx b/src/keycloak-theme/account/pages/Password.stories.tsx deleted file mode 100644 index 1ce118d..0000000 --- a/src/keycloak-theme/account/pages/Password.stories.tsx +++ /dev/null @@ -1,22 +0,0 @@ - -import { Meta, StoryObj } from '@storybook/react'; -import { createPageStory } from "../createPageStory"; - -const { PageStory } = createPageStory({ - pageId: "password.ftl" -}); - -const meta = { - title: "account/Password", - component: PageStory, -} satisfies Meta; -export default meta; -type Story = StoryObj; - -export const Default: Story = { - render: () => -}; diff --git a/src/keycloak-theme/account/pages/Password.tsx b/src/keycloak-theme/account/pages/Password.tsx deleted file mode 100644 index 294c759..0000000 --- a/src/keycloak-theme/account/pages/Password.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import { clsx } from "keycloakify/tools/clsx"; -import type { PageProps } from "keycloakify/account/pages/PageProps"; -import { useGetClassName } from "keycloakify/account/lib/useGetClassName"; -import type { KcContext } from "../kcContext"; -import type { I18n } from "../i18n"; - -export default function LogoutConfirm(props: PageProps, I18n>) { - const { kcContext, i18n, doUseDefaultCss, Template, classes } = props; - - const { getClassName } = useGetClassName({ - doUseDefaultCss, - "classes": { - ...classes, - "kcBodyClass": clsx(classes?.kcBodyClass, "password") - } - }); - - const { url, password, account, stateChecker } = kcContext; - - const { msg } = i18n; - - return ( - - ); -} diff --git a/src/keycloak-theme/login/KcApp.css b/src/keycloak-theme/login/KcApp.css deleted file mode 100644 index aeaebbe..0000000 --- a/src/keycloak-theme/login/KcApp.css +++ /dev/null @@ -1,16 +0,0 @@ - -.my-color { - color: red; -} - -.my-font { - font-family: 'Work Sans'; -} - -.my-root-class { - background: white; -} - -.my-root-class body { - background: url(./assets/background.svg) no-repeat center center fixed; -} \ No newline at end of file diff --git a/src/keycloak-theme/login/KcApp.tsx b/src/keycloak-theme/login/KcApp.tsx deleted file mode 100644 index 5366ad6..0000000 --- a/src/keycloak-theme/login/KcApp.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import "./KcApp.css"; -import { lazy, Suspense } from "react"; -import Fallback, { type PageProps } from "keycloakify/login"; -import type { KcContext } from "./kcContext"; -import { useI18n } from "./i18n"; -import Template from "./Template"; - -const Login = lazy(() => import("./pages/Login")); -// If you can, favor register-user-profile.ftl over register.ftl, see: https://docs.keycloakify.dev/realtime-input-validation -const Register = lazy(() => import("./pages/Register")); -const RegisterUserProfile = lazy(() => import("./pages/RegisterUserProfile")); -const Terms = lazy(() => import("./pages/Terms")); -const MyExtraPage1 = lazy(() => import("./pages/MyExtraPage1")); -const MyExtraPage2 = lazy(() => import("./pages/MyExtraPage2")); -const Info = lazy(() => import("keycloakify/login/pages/Info")); - -// This is like adding classes to theme.properties -// https://github.com/keycloak/keycloak/blob/11.0.3/themes/src/main/resources/theme/keycloak/login/theme.properties -const classes = { - // NOTE: The classes are defined in ./KcApp.css - "kcHtmlClass": "my-root-class", - "kcHeaderWrapperClass": "my-color my-font" -} satisfies PageProps["classes"]; - -export default function KcApp(props: { kcContext: KcContext; }) { - - const { kcContext } = props; - - const i18n = useI18n({ kcContext }); - - if (i18n === null) { - //NOTE: Text resources for the current language are still being downloaded, we can't display anything yet. - //We could display a loading progress but it's usually a matter of milliseconds. - return null; - } - - /* - * Examples assuming i18n.currentLanguageTag === "en": - * i18n.msg("access-denied") === Access denied - * i18n.msg("foo") === foo in English - */ - - return ( - - {(() => { - switch (kcContext.pageId) { - case "login.ftl": return ; - case "register.ftl": return ; - case "register-user-profile.ftl": return - case "terms.ftl": return ; - // Removes those pages in you project. They are included to show you how to implement keycloak pages - // that are not yes implemented by Keycloakify. - // See: https://docs.keycloakify.dev/limitations#some-pages-still-have-the-default-theme.-why - case "my-extra-page-1.ftl": return ; - case "my-extra-page-2.ftl": return ; - // We choose to use the default Template for the Info page and to download the theme resources. - // This is just an example to show you what is possible. You likely don't want to keep this as is. - case "info.ftl": return ( - import("keycloakify/login/Template"))} - doUseDefaultCss={true} - /> - ); - default: return ; - } - })()} - - ); - -} diff --git a/src/keycloak-theme/login/Template.tsx b/src/keycloak-theme/login/Template.tsx deleted file mode 100644 index 3ba0030..0000000 --- a/src/keycloak-theme/login/Template.tsx +++ /dev/null @@ -1,212 +0,0 @@ -// Copy pasted from: https://github.com/InseeFrLab/keycloakify/blob/main/src/login/Template.tsx - -import { useEffect } from "react"; -import { assert } from "keycloakify/tools/assert"; -import { clsx } from "keycloakify/tools/clsx"; -import { usePrepareTemplate } from "keycloakify/lib/usePrepareTemplate"; -import { type TemplateProps } from "keycloakify/login/TemplateProps"; -import { useGetClassName } from "keycloakify/login/lib/useGetClassName"; -import type { KcContext } from "./kcContext"; -import type { I18n } from "./i18n"; -import keycloakifyLogoPngUrl from "./assets/keycloakify-logo.png"; - -export default function Template(props: TemplateProps) { - const { - displayInfo = false, - displayMessage = true, - displayRequiredFields = false, - displayWide = false, - showAnotherWayIfPresent = true, - headerNode, - showUsernameNode = null, - infoNode = null, - kcContext, - i18n, - doUseDefaultCss, - classes, - children - } = props; - - const { getClassName } = useGetClassName({ doUseDefaultCss, classes }); - - const { msg, changeLocale, labelBySupportedLanguageTag, currentLanguageTag } = i18n; - - const { realm, locale, auth, url, message, isAppInitiatedAction } = kcContext; - - const { isReady } = usePrepareTemplate({ - "doFetchDefaultThemeResources": doUseDefaultCss, - "styles": [ - `${url.resourcesCommonPath}/node_modules/patternfly/dist/css/patternfly.min.css`, - `${url.resourcesCommonPath}/node_modules/patternfly/dist/css/patternfly-additions.min.css`, - `${url.resourcesCommonPath}/lib/zocial/zocial.css`, - `${url.resourcesPath}/css/login.css` - ], - "htmlClassName": getClassName("kcHtmlClass"), - "bodyClassName": getClassName("kcBodyClass"), - "htmlLangProperty": locale?.currentLanguageTag, - "documentTitle": i18n.msgStr("loginTitle", kcContext.realm.displayName) - }); - - useEffect(() => { - console.log(`Value of MY_ENV_VARIABLE on the Keycloak server: "${kcContext.properties.MY_ENV_VARIABLE}"`); - }, []); - - if (!isReady) { - return null; - } - - return ( -
-
-
- {/* - Here we are referencing the `keycloakify-logo.png` in the `public` directory. - When possible don't use this approach, instead ... - */} - Keycloakify logo - {msg("loginTitleHtml", realm.displayNameHtml)}!!! - {/* ...rely on the bundler to import your assets, it's more efficient */} - Keycloakify logo -
-
- -
-
- {realm.internationalizationEnabled && (assert(locale !== undefined), true) && 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 && showAnotherWayIfPresent && ( -
- -
- )} - {displayInfo && ( -
-
- {infoNode} -
-
- )} -
-
-
-
- ); -} diff --git a/src/keycloak-theme/login/assets/background.svg b/src/keycloak-theme/login/assets/background.svg deleted file mode 100644 index 0e1cada..0000000 --- a/src/keycloak-theme/login/assets/background.svg +++ /dev/null @@ -1,132 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/keycloak-theme/login/assets/keycloakify-logo.png b/src/keycloak-theme/login/assets/keycloakify-logo.png deleted file mode 100644 index b9d103c..0000000 Binary files a/src/keycloak-theme/login/assets/keycloakify-logo.png and /dev/null differ diff --git a/src/keycloak-theme/login/createPageStory.tsx b/src/keycloak-theme/login/createPageStory.tsx deleted file mode 100644 index cecb034..0000000 --- a/src/keycloak-theme/login/createPageStory.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { getKcContext, type KcContext } from "./kcContext"; -import KcApp from "./KcApp"; -import type { DeepPartial } from "keycloakify/tools/DeepPartial"; - -export function createPageStory(params: { - pageId: PageId; -}) { - - const { pageId } = params; - - function PageStory(params: { kcContext?: DeepPartial>; }) { - - const { kcContext } = getKcContext({ - mockPageId: pageId, - storyPartialKcContext: params.kcContext - }); - - return ( - <> - {/* If you import custom fonts in your index.html you have to import them in storybook as well*/} - - - - ); - - } - - return { PageStory }; - -} diff --git a/src/keycloak-theme/login/i18n.ts b/src/keycloak-theme/login/i18n.ts deleted file mode 100644 index b7b87f6..0000000 --- a/src/keycloak-theme/login/i18n.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { createUseI18n } from "keycloakify/login"; - -export const { useI18n } = createUseI18n({ - // NOTE: Here you can override the default i18n messages - // or define new ones that, for example, you would have - // defined in the Keycloak admin UI for UserProfile - // https://user-images.githubusercontent.com/6702424/182050652-522b6fe6-8ee5-49df-aca3-dba2d33f24a5.png - en: { - alphanumericalCharsOnly: "Only alphanumerical characters", - gender: "Gender", - // Here we overwrite the default english value for the message "doForgotPassword" - // that is "Forgot Password?" see: https://github.com/InseeFrLab/keycloakify/blob/f0ae5ea908e0aa42391af323b6d5e2fd371af851/src/lib/i18n/generated_messages/18.0.1/login/en.ts#L17 - doForgotPassword: "I forgot my password", - invalidUserMessage: "Invalid username or password. (this message was overwrite in the theme)" - }, - fr: { - /* spell-checker: disable */ - alphanumericalCharsOnly: "Caractère alphanumérique uniquement", - gender: "Genre", - doForgotPassword: "J'ai oublié mon mot de passe", - invalidUserMessage: "Nom d'utilisateur ou mot de passe invalide. (ce message a été écrasé dans le thème)" - /* spell-checker: enable */ - } -}); - -export type I18n = NonNullable>; diff --git a/src/keycloak-theme/login/kcContext.ts b/src/keycloak-theme/login/kcContext.ts deleted file mode 100644 index c104997..0000000 --- a/src/keycloak-theme/login/kcContext.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { createGetKcContext } from "keycloakify/login"; - -export type KcContextExtension = - | { pageId: "login.ftl"; } - | { pageId: "my-extra-page-1.ftl"; } - | { pageId: "my-extra-page-2.ftl"; someCustomValue: string; } - // NOTE: register.ftl is deprecated in favor of register-user-profile.ftl - // but let's say we use it anyway and have this plugin enabled: https://github.com/micedre/keycloak-mail-whitelisting - // keycloak-mail-whitelisting define the non standard ftl global authorizedMailDomains, we declare it here. - | { pageId: "register.ftl"; authorizedMailDomains: string[]; }; - -//NOTE: In most of the cases you do not need to overload the KcContext, you can -// just call createGetKcContext(...) without type arguments. -// You want to overload the KcContext only if: -// - You have custom plugins that add some values to the context (like https://github.com/micedre/keycloak-mail-whitelisting that adds authorizedMailDomains) -// - You want to add support for extra pages that are not yey featured by default, see: https://docs.keycloakify.dev/contributing#adding-support-for-a-new-page -export const { getKcContext } = createGetKcContext({ - mockData: [ - { - pageId: "login.ftl", - locale: { - //When we test the login page we do it in french - currentLanguageTag: "fr", - }, - //Uncomment the following line for hiding the Alert message - //"message": undefined - //Uncomment the following line for showing an Error message - //message: { type: "error", summary: "This is an error" } - }, - { - pageId: "my-extra-page-2.ftl", - someCustomValue: "foo bar baz", - }, - { - //NOTE: You will either use register.ftl (legacy) or register-user-profile.ftl, not both - pageId: "register-user-profile.ftl", - locale: { - currentLanguageTag: "fr" - }, - profile: { - attributes: [ - { - validators: { - pattern: { - pattern: "^[a-zA-Z0-9]+$", - "ignore.empty.value": true, - // eslint-disable-next-line no-template-curly-in-string - "error-message": "${alphanumericalCharsOnly}", - }, - }, - //NOTE: To override the default mock value - value: undefined, - name: "username" - }, - { - validators: { - options: { - options: ["male", "female", "non_binary", "prefer_not_to_say"] - } - }, - // eslint-disable-next-line no-template-curly-in-string - displayName: "${gender}", - annotations: {}, - required: true, - groupAnnotations: {}, - readOnly: false, - name: "gender" - } - ] - } - }, - { - pageId: "register.ftl", - authorizedMailDomains: [ - "example.com", - "another-example.com", - "*.yet-another-example.com", - "*.example.com", - "hello-world.com" - ], - // Simulate we got an error with the email field. Return text if message for given field exists. - messagesPerField: { - printIfExists: (fieldName: string, text: T) => { console.log({ fieldName }); return fieldName === "email" ? text : undefined; }, - existsError: (fieldName: string) => fieldName === "email", - get: (fieldName: string) => `Fake error for ${fieldName}`, - exists: (fieldName: string) => fieldName === "email" - }, - - } - ], - // Defined in vite.config.ts - // See: https://docs.keycloakify.dev/environnement-variables - mockProperties: { - MY_ENV_VARIABLE: "Mocked value" - } -}); - -export const { kcContext } = getKcContext({ - // Uncomment to test the login page for development. - //mockPageId: "login.ftl", -}); - - -export type KcContext = NonNullable["kcContext"]>; diff --git a/src/keycloak-theme/login/pages/Login.stories.tsx b/src/keycloak-theme/login/pages/Login.stories.tsx deleted file mode 100644 index 1b91ed2..0000000 --- a/src/keycloak-theme/login/pages/Login.stories.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { Meta, StoryObj } from '@storybook/react'; -import { createPageStory } from "../createPageStory"; - -const { PageStory } = createPageStory({ - pageId: "login.ftl" -}); - -const meta = { - title: "login/Login", - component: PageStory, -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -export const Default: Story = { - render: () => , -}; - -export const WithoutPasswordField: Story = { - render: () => , -}; - -export const WithoutRegistration: Story = { - render: () => , -}; - -export const WithoutRememberMe: Story = { - render: () => , -}; - -export const WithoutPasswordReset: Story = { - render: () => , -}; - -export const WithEmailAsUsername: Story = { - render: () => , -}; - -export const WithPresetUsername: Story = { - render: () => , -}; - -export const WithImmutablePresetUsername: Story = { - render: () => ( - - ), -}; - -export const WithSocialProviders: Story = { - render: () => ( - - ), -}; diff --git a/src/keycloak-theme/login/pages/Login.tsx b/src/keycloak-theme/login/pages/Login.tsx deleted file mode 100644 index 6f3e575..0000000 --- a/src/keycloak-theme/login/pages/Login.tsx +++ /dev/null @@ -1,204 +0,0 @@ -import { useState, type FormEventHandler } from "react"; -import { clsx } from "keycloakify/tools/clsx"; -import { useConstCallback } from "keycloakify/tools/useConstCallback"; -import type { PageProps } from "keycloakify/login/pages/PageProps"; -import { useGetClassName } from "keycloakify/login/lib/useGetClassName"; -import type { KcContext } from "../kcContext"; -import type { I18n } from "../i18n"; - -const my_custom_param = new URL(window.location.href).searchParams.get("my_custom_param"); - -if (my_custom_param !== null) { - console.log("my_custom_param:", my_custom_param); -} - -export default function Login(props: PageProps, I18n>) { - const { kcContext, i18n, doUseDefaultCss, Template, classes } = props; - - const { getClassName } = useGetClassName({ - doUseDefaultCss, - classes - }); - - const { social, realm, url, usernameHidden, login, auth, registrationDisabled } = kcContext; - - const { msg, msgStr } = i18n; - - const [isLoginButtonDisabled, setIsLoginButtonDisabled] = useState(false); - - const onSubmit = useConstCallback>(e => { - e.preventDefault(); - - setIsLoginButtonDisabled(true); - - const formElement = e.target as HTMLFormElement; - - //NOTE: Even if we login with email Keycloak expect username and password in - //the POST request. - formElement.querySelector("input[name='email']")?.setAttribute("name", "username"); - - formElement.submit(); - }); - - return ( - - ); -} diff --git a/src/keycloak-theme/login/pages/LoginResetPassword.stories.tsx b/src/keycloak-theme/login/pages/LoginResetPassword.stories.tsx deleted file mode 100644 index 0b8b9d8..0000000 --- a/src/keycloak-theme/login/pages/LoginResetPassword.stories.tsx +++ /dev/null @@ -1,30 +0,0 @@ -//This is to show that you can create stories for pages that you haven't overloaded. - -import { Meta, StoryObj } from '@storybook/react'; -import { createPageStory } from "../createPageStory"; - -const { PageStory } = createPageStory({ - pageId: "login-reset-password.ftl" -}); - -const meta = { - title: "login/LoginResetPassword", - component: PageStory, -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -export const Default: Story = { - render: () => -}; - -export const WithEmailAsUsername: Story = { - render: () => ( - - ) -}; diff --git a/src/keycloak-theme/login/pages/MyExtraPage1.tsx b/src/keycloak-theme/login/pages/MyExtraPage1.tsx deleted file mode 100644 index b7c73bd..0000000 --- a/src/keycloak-theme/login/pages/MyExtraPage1.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import type { PageProps } from "keycloakify/login/pages/PageProps"; -import type { KcContext } from "../kcContext"; -import type { I18n } from "../i18n"; - -export default function MyExtraPage1(props: PageProps, I18n>) { - - const { kcContext, i18n, doUseDefaultCss, Template, classes } = props; - - return ( - - ); - -} diff --git a/src/keycloak-theme/login/pages/MyExtraPage2.stories.tsx b/src/keycloak-theme/login/pages/MyExtraPage2.stories.tsx deleted file mode 100644 index b412140..0000000 --- a/src/keycloak-theme/login/pages/MyExtraPage2.stories.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { Meta, StoryObj } from '@storybook/react'; -import { createPageStory } from "../createPageStory"; - -const { PageStory } = createPageStory({ - pageId: "my-extra-page-2.ftl" -}); - -const meta = { - title: "login/MyExtraPage2", - component: PageStory, -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -export const Default: Story = { - render: () => -}; - -export const WitAbc: Story = { - render: () => ( - - ) -}; diff --git a/src/keycloak-theme/login/pages/MyExtraPage2.tsx b/src/keycloak-theme/login/pages/MyExtraPage2.tsx deleted file mode 100644 index 7fbdc96..0000000 --- a/src/keycloak-theme/login/pages/MyExtraPage2.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import type { PageProps } from "keycloakify/login/pages/PageProps"; -import type { KcContext } from "../kcContext"; -import type { I18n } from "../i18n"; - -export default function MyExtraPage1(props: PageProps, I18n>) { - - const { kcContext, i18n, doUseDefaultCss, Template, classes } = props; - - // someCustomValue is declared by you in ../kcContext.ts - console.log(`TODO: Do something with: ${kcContext.someCustomValue}`); - - return ( - - ); - -} diff --git a/src/keycloak-theme/login/pages/Register.tsx b/src/keycloak-theme/login/pages/Register.tsx deleted file mode 100644 index 9275a09..0000000 --- a/src/keycloak-theme/login/pages/Register.tsx +++ /dev/null @@ -1,183 +0,0 @@ -// ejected using 'npx eject-keycloak-page' -import { clsx } from "keycloakify/tools/clsx"; -import type { PageProps } from "keycloakify/login/pages/PageProps"; -import { useGetClassName } from "keycloakify/login/lib/useGetClassName"; -import type { KcContext } from "../kcContext"; -import type { I18n } from "../i18n"; - -export default function Register(props: PageProps, I18n>) { - const { kcContext, i18n, doUseDefaultCss, Template, classes } = props; - - const { getClassName } = useGetClassName({ - doUseDefaultCss, - classes - }); - - const { url, messagesPerField, register, realm, passwordRequired, recaptchaRequired, recaptchaSiteKey } = kcContext; - - const { msg, msgStr } = i18n; - - return ( - - ); -} diff --git a/src/keycloak-theme/login/pages/RegisterUserProfile.tsx b/src/keycloak-theme/login/pages/RegisterUserProfile.tsx deleted file mode 100644 index 09bf27e..0000000 --- a/src/keycloak-theme/login/pages/RegisterUserProfile.tsx +++ /dev/null @@ -1,71 +0,0 @@ -// ejected using 'npx eject-keycloak-page' -import { useState } from "react"; -import { clsx } from "keycloakify/tools/clsx"; -import { UserProfileFormFields } from "./shared/UserProfileFormFields"; -import type { PageProps } from "keycloakify/login/pages/PageProps"; -import { useGetClassName } from "keycloakify/login/lib/useGetClassName"; -import type { KcContext } from "../kcContext"; -import type { I18n } from "../i18n"; - -export default function RegisterUserProfile(props: PageProps, I18n>) { - const { kcContext, i18n, doUseDefaultCss, Template, classes } = props; - - const { getClassName } = useGetClassName({ - doUseDefaultCss, - classes - }); - - const { url, messagesPerField, recaptchaRequired, recaptchaSiteKey } = kcContext; - - const { msg, msgStr } = i18n; - - const [isFormSubmittable, setIsFormSubmittable] = useState(false); - - return ( - - ); -} diff --git a/src/keycloak-theme/login/pages/Terms.stories.tsx b/src/keycloak-theme/login/pages/Terms.stories.tsx deleted file mode 100644 index 35d2847..0000000 --- a/src/keycloak-theme/login/pages/Terms.stories.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { Meta, StoryObj } from '@storybook/react'; -import { createPageStory } from "../createPageStory"; - -const { PageStory } = createPageStory({ - pageId: "terms.ftl" -}); - -const meta = { - title: "login/Terms", - component: PageStory, -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -export const Primary: Story = { - render: () => -}; diff --git a/src/keycloak-theme/login/pages/Terms.tsx b/src/keycloak-theme/login/pages/Terms.tsx deleted file mode 100644 index b1998d0..0000000 --- a/src/keycloak-theme/login/pages/Terms.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { clsx } from "keycloakify/tools/clsx"; -import { useRerenderOnStateChange } from "evt/hooks"; -import { Markdown } from "keycloakify/tools/Markdown"; -import type { PageProps } from "keycloakify/login/pages/PageProps"; -import { useGetClassName } from "keycloakify/login/lib/useGetClassName"; -import { evtTermMarkdown } from "keycloakify/login/lib/useDownloadTerms"; -import type { KcContext } from "../kcContext"; -import type { I18n } from "../i18n"; -import { useDownloadTerms } from "keycloakify/login"; - -export default function Terms(props: PageProps, I18n>) { - const { kcContext, i18n, doUseDefaultCss, Template, classes } = props; - - const { getClassName } = useGetClassName({ - doUseDefaultCss, - classes - }); - - const { msg, msgStr } = i18n; - - // NOTE: If you aren't going to customize the layout of the page you can move this hook to - // KcApp.tsx, see: https://docs.keycloakify.dev/terms-and-conditions - useDownloadTerms({ - kcContext, - "downloadTermMarkdown": async ({currentLanguageTag}) => { - - const tos_url = (() => { - switch (currentLanguageTag) { - case "fr": return `${import.meta.env.BASE_URL}terms/fr.md`; - default: return `${import.meta.env.BASE_URL}terms/en.md`; - } - })(); - - const markdownString = await fetch(tos_url).then(response => response.text()); - - return markdownString; - - } - }); - - useRerenderOnStateChange(evtTermMarkdown); - - const { url } = kcContext; - - const termMarkdown = evtTermMarkdown.state; - - if (termMarkdown === undefined) { - return null; - } - - return ( - - ); -} diff --git a/src/keycloak-theme/login/pages/shared/UserProfileFormFields.tsx b/src/keycloak-theme/login/pages/shared/UserProfileFormFields.tsx deleted file mode 100644 index a95d47b..0000000 --- a/src/keycloak-theme/login/pages/shared/UserProfileFormFields.tsx +++ /dev/null @@ -1,177 +0,0 @@ -import { useEffect, Fragment } from "react"; -import type { ClassKey } from "keycloakify/login/TemplateProps"; -import { clsx } from "keycloakify/tools/clsx"; -import { useFormValidation } from "keycloakify/login/lib/useFormValidation"; -import type { Attribute } from "keycloakify/login/kcContext/KcContext"; -import type { I18n } from "../../i18n"; - -export type UserProfileFormFieldsProps = { - kcContext: Parameters[0]["kcContext"]; - i18n: I18n; - getClassName: (classKey: ClassKey) => string; - onIsFormSubmittableValueChange: (isFormSubmittable: boolean) => void; - BeforeField?: (props: { attribute: Attribute }) => JSX.Element | null; - AfterField?: (props: { attribute: Attribute }) => JSX.Element | null; -}; - -export function UserProfileFormFields(props: UserProfileFormFieldsProps) { - const { kcContext, onIsFormSubmittableValueChange, i18n, getClassName, BeforeField, AfterField } = props; - - const { advancedMsg, msg } = i18n; - - const { - formValidationState: { fieldStateByAttributeName, isFormSubmittable }, - formValidationDispatch, - attributesWithPassword - } = useFormValidation({ - kcContext, - i18n - }); - - useEffect(() => { - onIsFormSubmittableValueChange(isFormSubmittable); - }, [isFormSubmittable]); - - let currentGroup = ""; - - return ( - <> - {attributesWithPassword.map((attribute, i) => { - const { group = "", groupDisplayHeader = "", groupDisplayDescription = "" } = attribute; - - const { value, displayableErrors } = fieldStateByAttributeName[attribute.name]; - - const formGroupClassName = clsx( - getClassName("kcFormGroupClass"), - displayableErrors.length !== 0 && getClassName("kcFormGroupErrorClass") - ); - - return ( - - {group !== currentGroup && (currentGroup = group) !== "" && ( -
-
- -
- {groupDisplayDescription !== "" && ( -
- -
- )} -
- )} - - {BeforeField && } - -
-
- - {attribute.required && <>*} -
-
- {(() => { - const { options } = attribute.validators; - - if (options !== undefined) { - return ( - - ); - } - - return ( - { - switch (attribute.name) { - case "password-confirm": - case "password": - return "password"; - default: - return "text"; - } - })()} - id={attribute.name} - name={attribute.name} - value={value} - onChange={event => - formValidationDispatch({ - "action": "update value", - "name": attribute.name, - "newValue": event.target.value - }) - } - onBlur={() => - formValidationDispatch({ - "action": "focus lost", - "name": attribute.name - }) - } - className={getClassName("kcInputClass")} - aria-invalid={displayableErrors.length !== 0} - disabled={attribute.readOnly} - autoComplete={attribute.autocomplete} - /> - ); - })()} - {displayableErrors.length !== 0 && - (() => { - const divId = `input-error-${attribute.name}`; - - return ( - <> - - - {displayableErrors.map(({ errorMessage }) => errorMessage)} - - - ); - })()} -
-
- {AfterField && } -
- ); - })} - - ); -} \ No newline at end of file diff --git a/src/login/KcApp.tsx b/src/login/KcApp.tsx new file mode 100644 index 0000000..6ec7fc7 --- /dev/null +++ b/src/login/KcApp.tsx @@ -0,0 +1,57 @@ +import { Suspense, lazy } from "react"; +import type { KcContext } from "./KcContext"; +import { useI18n } from "./i18n"; +import { useDownloadTerms } from "keycloakify/login"; + +const Fallback = lazy(() => import("keycloakify/login/Fallback")); +const Template = lazy(() => import("./Template")); +const UserProfileFormFields = lazy(() => import("./UserProfileFormFields")); + +export default function KcApp(props: { kcContext: KcContext }) { + const { kcContext } = props; + + const i18n = useI18n({ kcContext }); + + useDownloadTerms({ + kcContext, + downloadTermMarkdown: async ({ currentLanguageTag }) => { + + const termsFileName = (() => { + switch (currentLanguageTag) { + case "fr": return "fr.md"; + case "es": return "es.md"; + default: return "en.md"; + } + })(); + + // The files are in the public directory. + const response = await fetch(`${import.meta.env}terms/${termsFileName}`); + + return response.text(); + + } + }); + + if (i18n === null) { + return null; + } + + return ( + + {(() => { + switch (kcContext.pageId) { + default: + return + } + })()} + + ); +} diff --git a/src/login/KcContext.ts b/src/login/KcContext.ts new file mode 100644 index 0000000..1898a16 --- /dev/null +++ b/src/login/KcContext.ts @@ -0,0 +1,8 @@ +/* eslint-disable @typescript-eslint/ban-types */ +import type { ExtendKcContext } from "keycloakify/login"; + +export type KcContextExtraProperties = {}; + +export type KcContextExtraPropertiesPerPage = {}; + +export type KcContext = ExtendKcContext; diff --git a/src/login/PageStory.tsx b/src/login/PageStory.tsx new file mode 100644 index 0000000..1c89573 --- /dev/null +++ b/src/login/PageStory.tsx @@ -0,0 +1,40 @@ +import type { DeepPartial } from "keycloakify/tools/DeepPartial"; +import type { KcContext } from "./kcContext"; +import KcApp from "./KcApp"; +import { createGetKcContextMock } from "keycloakify/login"; +import type { + KcContextExtraProperties, + KcContextExtraPropertiesPerPage +} from "./kcContext"; + +const kcContextExtraProperties: KcContextExtraProperties = {}; +const kcContextExtraPropertiesPerPage: KcContextExtraPropertiesPerPage = {}; + +export const { getKcContextMock } = createGetKcContextMock({ + kcContextExtraProperties, + kcContextExtraPropertiesPerPage, + overrides: {}, + overridesPerPage: {} +}); + +export function createPageStory(params: { pageId: PageId }) { + const { pageId } = params; + + function PageStory(props: { kcContext?: DeepPartial> }) { + const { kcContext: overrides } = props; + + const kcContextMock = getKcContextMock({ + pageId, + overrides + }); + + return ( + <> + + + ); + } + + return { PageStory }; +} + diff --git a/src/login/Template.tsx b/src/login/Template.tsx new file mode 100644 index 0000000..5ac6965 --- /dev/null +++ b/src/login/Template.tsx @@ -0,0 +1,278 @@ +// 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 new file mode 100644 index 0000000..2b6264f --- /dev/null +++ b/src/login/UserProfileFormFields.tsx @@ -0,0 +1,699 @@ +// 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 ( +