Migrate to Keycloakify v10

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

View file

@ -1,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 },
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',
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.*'],
files: ["**/*.stories.*"],
rules: {
'import/no-anonymous-default-export': 'off',
},
},
],
}
"import/no-anonymous-default-export": "off"
}
}
]
};

View file

@ -8,7 +8,6 @@ on:
- main
jobs:
test:
runs-on: ubuntu-latest
steps:
@ -59,4 +58,3 @@ jobs:
keycloak-theme.jar
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

6
.prettierignore Normal file
View file

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

24
.prettierrc.json Normal file
View file

@ -0,0 +1,24 @@
{
"printWidth": 90,
"tabWidth": 4,
"useTabs": false,
"semi": true,
"singleQuote": false,
"trailingComma": "none",
"bracketSpacing": true,
"arrowParens": "avoid",
"overrides": [
{
"files": [
"**/login/pages/*.tsx",
"**/account/pages/*.tsx",
"**/login/Template.tsx",
"**/account/Template.tsx",
"**/login/UserProfileFormFields.tsx"
],
"options": {
"printWidth": 150
}
}
]
}

View file

@ -6,14 +6,14 @@ const config: StorybookConfig = {
"@storybook/addon-links",
"@storybook/addon-essentials",
"@storybook/addon-onboarding",
"@storybook/addon-interactions",
"@storybook/addon-interactions"
],
framework: {
name: "@storybook/react-vite",
options: {},
options: {}
},
docs: {
autodocs: "tag",
autodocs: "tag"
},
staticDirs: ["../public"]
};

View file

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

View file

@ -5,10 +5,10 @@ const preview: Preview = {
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
},
date: /Date$/i
}
}
}
};
export default preview;

View file

@ -93,7 +93,6 @@ You can enable this feature by providing multiple theme name in the Keycloakify
![image](https://user-images.githubusercontent.com/6702424/229296556-a69f2dc9-4653-475c-9c89-d53cf33dc05a.png)
# The storybook
![image](https://github.com/keycloakify/keycloakify/assets/6702424/a18ac1ff-dcfd-4b8c-baed-dcda5aa1d762)

View file

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

View file

@ -12,12 +12,13 @@
"build": "tsc && vite build",
"build-keycloak-theme": "yarn build && keycloakify",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
"build-storybook": "storybook build",
"format": "npx prettier . --write"
},
"license": "MIT",
"keywords": [],
"dependencies": {
"keycloakify": "10.0.0-rc.31",
"keycloakify": "10.0.0-rc.33",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
@ -39,6 +40,7 @@
"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"

View file

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

View file

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

View file

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

View file

@ -1,159 +0,0 @@
// Copy pasted from: https://github.com/keycloakify/keycloakify/blob/main/src/account/Template.tsx
import { useEffect } from "react";
import { assert } from "keycloakify/tools/assert";
import { clsx } from "keycloakify/tools/clsx";
import { useGetClassName } from "keycloakify/account/lib/useGetClassName";
import { useInsertLinkTags } from "keycloakify/tools/useInsertLinkTags";
import { useSetClassName } from "keycloakify/tools/useSetClassName";
import type { TemplateProps } from "keycloakify/account/TemplateProps";
import type { KcContext } from "./KcContext";
import type { I18n } from "./i18n";
export default function Template(props: TemplateProps<KcContext, I18n>) {
const { kcContext, i18n, doUseDefaultCss, active, classes, children } = props;
const { getClassName } = useGetClassName({ doUseDefaultCss, classes });
const { msg, msgStr, getChangeLocalUrl, labelBySupportedLanguageTag, currentLanguageTag } = i18n;
const { locale, url, features, realm, message, referrer } = kcContext;
useEffect(() => {
document.title = msgStr("accountManagementTitle");
}, []);
useSetClassName({
qualifiedName: "html",
className: getClassName("kcHtmlClass")
});
useSetClassName({
qualifiedName: "body",
className: clsx("admin-console", "user", getClassName("kcBodyClass"))
});
useEffect(() => {
const { currentLanguageTag } = locale ?? {};
if (currentLanguageTag === undefined) {
return;
}
const html = document.querySelector("html");
assert(html !== null);
html.lang = currentLanguageTag;
}, []);
const { areAllStyleSheetsLoaded } = useInsertLinkTags({
componentOrHookName: "Template",
hrefs: !doUseDefaultCss
? []
: [
`${url.resourcesCommonPath}/node_modules/patternfly/dist/css/patternfly.min.css`,
`${url.resourcesCommonPath}/node_modules/patternfly/dist/css/patternfly-additions.min.css`,
`${url.resourcesPath}/css/account.css`
]
});
if (!areAllStyleSheetsLoaded) {
return null;
}
return (
<>
<header className="navbar navbar-default navbar-pf navbar-main header">
<nav className="navbar" role="navigation">
<div className="navbar-header">
<div className="container">
<h1 className="navbar-title">Keycloak</h1>
</div>
</div>
<div className="navbar-collapse navbar-collapse-1">
<div className="container">
<ul className="nav navbar-nav navbar-utility">
{realm.internationalizationEnabled && (assert(locale !== undefined), true) && locale.supported.length > 1 && (
<li>
<div className="kc-dropdown" id="kc-locale-dropdown">
<a href="#" id="kc-current-locale-link">
{labelBySupportedLanguageTag[currentLanguageTag]}
</a>
<ul>
{locale.supported.map(({ languageTag }) => (
<li key={languageTag} className="kc-dropdown-item">
<a href={getChangeLocalUrl(languageTag)}>{labelBySupportedLanguageTag[languageTag]}</a>
</li>
))}
</ul>
</div>
</li>
)}
{referrer?.url && (
<li>
<a href={referrer.url} id="referrer">
{msg("backTo", referrer.name)}
</a>
</li>
)}
<li>
<a href={url.getLogoutUrl()}>{msg("doSignOut")}</a>
</li>
</ul>
</div>
</div>
</nav>
</header>
<div className="container">
<div className="bs-sidebar col-sm-3">
<ul>
<li className={clsx(active === "account" && "active")}>
<a href={url.accountUrl}>{msg("account")}</a>
</li>
{features.passwordUpdateSupported && (
<li className={clsx(active === "password" && "active")}>
<a href={url.passwordUrl}>{msg("password")}</a>
</li>
)}
<li className={clsx(active === "totp" && "active")}>
<a href={url.totpUrl}>{msg("authenticator")}</a>
</li>
{features.identityFederation && (
<li className={clsx(active === "social" && "active")}>
<a href={url.socialUrl}>{msg("federatedIdentity")}</a>
</li>
)}
<li className={clsx(active === "sessions" && "active")}>
<a href={url.sessionsUrl}>{msg("sessions")}</a>
</li>
<li className={clsx(active === "applications" && "active")}>
<a href={url.applicationsUrl}>{msg("applications")}</a>
</li>
{features.log && (
<li className={clsx(active === "log" && "active")}>
<a href={url.logUrl}>{msg("log")}</a>
</li>
)}
{realm.userManagedAccessAllowed && features.authorization && (
<li className={clsx(active === "authorization" && "active")}>
<a href={url.resourceUrl}>{msg("myResources")}</a>
</li>
)}
</ul>
</div>
<div className="col-sm-9 content-area">
{message !== undefined && (
<div className={clsx("alert", `alert-${message.type}`)}>
{message.type === "success" && <span className="pficon pficon-ok"></span>}
{message.type === "error" && <span className="pficon pficon-error-circle-o"></span>}
<span className="kc-feedback-text">{message.summary}</span>
</div>
)}
{children}
</div>
</div>
</>
);
}

View file

@ -0,0 +1,19 @@
import type { Meta, StoryObj } from "@storybook/react";
import { createPageStory } from "../PageStory";
const pageId = "account.ftl";
const { PageStory } = createPageStory({ pageId });
const meta = {
title: `account/${pageId}`,
component: PageStory
} satisfies Meta<typeof PageStory>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
render: () => <PageStory />
};

View file

@ -0,0 +1,29 @@
import type { Meta, StoryObj } from "@storybook/react";
import { createPageStory } from "../PageStory";
const pageId = "password.ftl";
const { PageStory } = createPageStory({ pageId });
const meta = {
title: `account/${pageId}`,
component: PageStory
} satisfies Meta<typeof PageStory>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
render: () => <PageStory />
};
export const WithMessage: Story = {
render: () => (
<PageStory
kcContext={{
message: { type: "success", summary: "This is a test message" }
}}
/>
)
};

View file

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

View file

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

View file

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

View file

@ -1,278 +0,0 @@
// Copy pasted from: https://github.com/keycloakify/keycloakify/blob/main/src/login/Template.tsx
import { useEffect } from "react";
import { assert } from "keycloakify/tools/assert";
import { clsx } from "keycloakify/tools/clsx";
import type { TemplateProps } from "keycloakify/login/TemplateProps";
import { useGetClassName } from "keycloakify/login/lib/useGetClassName";
import { useInsertScriptTags } from "keycloakify/tools/useInsertScriptTags";
import { useInsertLinkTags } from "keycloakify/tools/useInsertLinkTags";
import { useSetClassName } from "keycloakify/tools/useSetClassName";
import type { KcContext } from "./KcContext";
import type { I18n } from "./i18n";
export default function Template(props: TemplateProps<KcContext, I18n>) {
const {
displayInfo = false,
displayMessage = true,
displayRequiredFields = false,
headerNode,
showUsernameNode = null,
socialProvidersNode = null,
infoNode = null,
documentTitle,
bodyClassName,
kcContext,
i18n,
doUseDefaultCss,
classes,
children
} = props;
const { getClassName } = useGetClassName({ doUseDefaultCss, classes });
const { msg, msgStr, getChangeLocalUrl, labelBySupportedLanguageTag, currentLanguageTag } = i18n;
const { realm, locale, auth, url, message, isAppInitiatedAction, authenticationSession, scripts } = kcContext;
useEffect(() => {
document.title = documentTitle ?? msgStr("loginTitle", kcContext.realm.displayName);
}, []);
useSetClassName({
qualifiedName: "html",
className: getClassName("kcHtmlClass")
});
useSetClassName({
qualifiedName: "body",
className: bodyClassName ?? getClassName("kcBodyClass")
});
useEffect(() => {
const { currentLanguageTag } = locale ?? {};
if (currentLanguageTag === undefined) {
return;
}
const html = document.querySelector("html");
assert(html !== null);
html.lang = currentLanguageTag;
}, []);
const { areAllStyleSheetsLoaded } = useInsertLinkTags({
componentOrHookName: "Template",
hrefs: !doUseDefaultCss
? []
: [
`${url.resourcesCommonPath}/node_modules/@patternfly/patternfly/patternfly.min.css`,
`${url.resourcesCommonPath}/node_modules/patternfly/dist/css/patternfly.min.css`,
`${url.resourcesCommonPath}/node_modules/patternfly/dist/css/patternfly-additions.min.css`,
`${url.resourcesCommonPath}/lib/pficon/pficon.css`,
`${url.resourcesPath}/css/login.css`
]
});
const { insertScriptTags } = useInsertScriptTags({
componentOrHookName: "Template",
scriptTags: [
{
type: "module",
src: `${url.resourcesPath}/js/menu-button-links.js`
},
...(authenticationSession === undefined
? []
: [
{
type: "module",
textContent: [
`import { checkCookiesAndSetTimer } from "${url.resourcesPath}/js/authChecker.js";`,
``,
`checkCookiesAndSetTimer(`,
` "${authenticationSession.authSessionId}",`,
` "${authenticationSession.tabId}",`,
` "${url.ssoLoginInOtherTabsUrl}"`,
`);`
].join("\n")
} as const
]),
...scripts.map(
script =>
({
type: "text/javascript",
src: script
}) as const
)
]
});
useEffect(() => {
if (areAllStyleSheetsLoaded) {
insertScriptTags();
}
}, [areAllStyleSheetsLoaded]);
if (!areAllStyleSheetsLoaded) {
return null;
}
return (
<div className={getClassName("kcLoginClass")}>
<div id="kc-header" className={getClassName("kcHeaderClass")}>
<div id="kc-header-wrapper" className={getClassName("kcHeaderWrapperClass")}>
{msg("loginTitleHtml", realm.displayNameHtml)}
</div>
</div>
<div className={getClassName("kcFormCardClass")}>
<header className={getClassName("kcFormHeaderClass")}>
{realm.internationalizationEnabled && (assert(locale !== undefined), locale.supported.length > 1) && (
<div className={getClassName("kcLocaleMainClass")} id="kc-locale">
<div id="kc-locale-wrapper" className={getClassName("kcLocaleWrapperClass")}>
<div id="kc-locale-dropdown" className={clsx("menu-button-links", getClassName("kcLocaleDropDownClass"))}>
<button
tabIndex={1}
id="kc-current-locale-link"
aria-label={msgStr("languages")}
aria-haspopup="true"
aria-expanded="false"
aria-controls="language-switch1"
>
{labelBySupportedLanguageTag[currentLanguageTag]}
</button>
<ul
role="menu"
tabIndex={-1}
aria-labelledby="kc-current-locale-link"
aria-activedescendant=""
id="language-switch1"
className={getClassName("kcLocaleListClass")}
>
{locale.supported.map(({ languageTag }, i) => (
<li key={languageTag} className={getClassName("kcLocaleListItemClass")} role="none">
<a
role="menuitem"
id={`language-${i + 1}`}
className={getClassName("kcLocaleItemClass")}
href={getChangeLocalUrl(languageTag)}
>
{labelBySupportedLanguageTag[languageTag]}
</a>
</li>
))}
</ul>
</div>
</div>
</div>
)}
{!(auth !== undefined && auth.showUsername && !auth.showResetCredentials) ? (
displayRequiredFields ? (
<div className={getClassName("kcContentWrapperClass")}>
<div className={clsx(getClassName("kcLabelWrapperClass"), "subtitle")}>
<span className="subtitle">
<span className="required">*</span>
{msg("requiredFields")}
</span>
</div>
<div className="col-md-10">
<h1 id="kc-page-title">{headerNode}</h1>
</div>
</div>
) : (
<h1 id="kc-page-title">{headerNode}</h1>
)
) : displayRequiredFields ? (
<div className={getClassName("kcContentWrapperClass")}>
<div className={clsx(getClassName("kcLabelWrapperClass"), "subtitle")}>
<span className="subtitle">
<span className="required">*</span> {msg("requiredFields")}
</span>
</div>
<div className="col-md-10">
{showUsernameNode}
<div id="kc-username" className={getClassName("kcFormGroupClass")}>
<label id="kc-attempted-username">{auth.attemptedUsername}</label>
<a id="reset-login" href={url.loginRestartFlowUrl} aria-label={msgStr("restartLoginTooltip")}>
<div className="kc-login-tooltip">
<i className={getClassName("kcResetFlowIcon")}></i>
<span className="kc-tooltip-text">{msg("restartLoginTooltip")}</span>
</div>
</a>
</div>
</div>
</div>
) : (
<>
{showUsernameNode}
<div id="kc-username" className={getClassName("kcFormGroupClass")}>
<label id="kc-attempted-username">{auth.attemptedUsername}</label>
<a id="reset-login" href={url.loginRestartFlowUrl} aria-label={msgStr("restartLoginTooltip")}>
<div className="kc-login-tooltip">
<i className={getClassName("kcResetFlowIcon")}></i>
<span className="kc-tooltip-text">{msg("restartLoginTooltip")}</span>
</div>
</a>
</div>
</>
)}
</header>
<div id="kc-content">
<div id="kc-content-wrapper">
{/* App-initiated actions should not see warning messages about the need to complete the action during login. */}
{displayMessage && message !== undefined && (message.type !== "warning" || !isAppInitiatedAction) && (
<div
className={clsx(
`alert-${message.type}`,
getClassName("kcAlertClass"),
`pf-m-${message?.type === "error" ? "danger" : message.type}`
)}
>
<div className="pf-c-alert__icon">
{message.type === "success" && <span className={getClassName("kcFeedbackSuccessIcon")}></span>}
{message.type === "warning" && <span className={getClassName("kcFeedbackWarningIcon")}></span>}
{message.type === "error" && <span className={getClassName("kcFeedbackErrorIcon")}></span>}
{message.type === "info" && <span className={getClassName("kcFeedbackInfoIcon")}></span>}
</div>
<span
className={getClassName("kcAlertTitleClass")}
dangerouslySetInnerHTML={{
__html: message.summary
}}
/>
</div>
)}
{children}
{auth !== undefined && auth.showTryAnotherWayLink && (
<form id="kc-select-try-another-way-form" action={url.loginAction} method="post">
<div className={getClassName("kcFormGroupClass")}>
<div className={getClassName("kcFormGroupClass")}>
<input type="hidden" name="tryAnotherWay" value="on" />
<a
href="#"
id="try-another-way"
onClick={() => {
document.forms["kc-select-try-another-way-form" as never].submit();
return false;
}}
>
{msg("doTryAnotherWay")}
</a>
</div>
</div>
</form>
)}
{socialProvidersNode}
{displayInfo && (
<div id="kc-info" className={getClassName("kcSignUpClass")}>
<div id="kc-info-wrapper" className={getClassName("kcInfoAreaWrapperClass")}>
{infoNode}
</div>
</div>
)}
</div>
</div>
</div>
</div>
);
}

View file

@ -1,699 +0,0 @@
// Copy pasted from: https://github.com/keycloakify/keycloakify/blob/main/src/login/UserProfileFormFields.tsx
import { useEffect, useReducer, Fragment } from "react";
import { assert } from "tsafe/assert";
import type { ClassKey } from "keycloakify/login/TemplateProps";
import {
useUserProfileForm,
getButtonToDisplayForMultivaluedAttributeField,
type KcContextLike,
type FormAction,
type FormFieldError
} from "keycloakify/login/lib/useUserProfileForm";
import type { Attribute } from "keycloakify/login/KcContext";
import type { I18n } from "./i18n";
export type UserProfileFormFieldsProps = {
kcContext: KcContextLike;
i18n: I18n;
getClassName: (classKey: ClassKey) => string;
onIsFormSubmittableValueChange: (isFormSubmittable: boolean) => void;
BeforeField?: (props: BeforeAfterFieldProps) => JSX.Element | null;
AfterField?: (props: BeforeAfterFieldProps) => JSX.Element | null;
};
type BeforeAfterFieldProps = {
attribute: Attribute;
dispatchFormAction: React.Dispatch<FormAction>;
displayableErrors: FormFieldError[];
i18n: I18n;
valueOrValues: string | string[];
};
// NOTE: Enabled by default but it's a UX best practice to set it to false.
const doMakeUserConfirmPassword = true;
export default function UserProfileFormFields(props: UserProfileFormFieldsProps) {
const { kcContext, onIsFormSubmittableValueChange, i18n, getClassName, BeforeField, AfterField } = props;
const { advancedMsg } = i18n;
const {
formState: { formFieldStates, isFormSubmittable },
dispatchFormAction
} = useUserProfileForm({
kcContext,
i18n,
doMakeUserConfirmPassword
});
useEffect(() => {
onIsFormSubmittableValueChange(isFormSubmittable);
}, [isFormSubmittable]);
const groupNameRef = { current: "" };
return (
<>
{formFieldStates.map(({ attribute, displayableErrors, valueOrValues }) => {
return (
<Fragment key={attribute.name}>
<GroupLabel attribute={attribute} getClassName={getClassName} i18n={i18n} groupNameRef={groupNameRef} />
{BeforeField !== undefined && (
<BeforeField
attribute={attribute}
dispatchFormAction={dispatchFormAction}
displayableErrors={displayableErrors}
i18n={i18n}
valueOrValues={valueOrValues}
/>
)}
<div
className={getClassName("kcFormGroupClass")}
style={{
display: attribute.name === "password-confirm" && !doMakeUserConfirmPassword ? "none" : undefined
}}
>
<div className={getClassName("kcLabelWrapperClass")}>
<label htmlFor={attribute.name} className={getClassName("kcLabelClass")}>
{advancedMsg(attribute.displayName ?? "")}
</label>
{attribute.required && <>*</>}
</div>
<div className={getClassName("kcInputWrapperClass")}>
{attribute.annotations.inputHelperTextBefore !== undefined && (
<div
className={getClassName("kcInputHelperTextBeforeClass")}
id={`form-help-text-before-${attribute.name}`}
aria-live="polite"
>
{advancedMsg(attribute.annotations.inputHelperTextBefore)}
</div>
)}
<InputFiledByType
attribute={attribute}
valueOrValues={valueOrValues}
displayableErrors={displayableErrors}
formValidationDispatch={dispatchFormAction}
getClassName={getClassName}
i18n={i18n}
/>
<FieldErrors
attribute={attribute}
getClassName={getClassName}
displayableErrors={displayableErrors}
fieldIndex={undefined}
/>
{attribute.annotations.inputHelperTextAfter !== undefined && (
<div
className={getClassName("kcInputHelperTextAfterClass")}
id={`form-help-text-after-${attribute.name}`}
aria-live="polite"
>
{advancedMsg(attribute.annotations.inputHelperTextAfter)}
</div>
)}
{AfterField !== undefined && (
<AfterField
attribute={attribute}
dispatchFormAction={dispatchFormAction}
displayableErrors={displayableErrors}
i18n={i18n}
valueOrValues={valueOrValues}
/>
)}
{/* NOTE: Downloading of html5DataAnnotations scripts is done in the useUserProfileForm hook */}
</div>
</div>
</Fragment>
);
})}
</>
);
}
function GroupLabel(props: {
attribute: Attribute;
getClassName: UserProfileFormFieldsProps["getClassName"];
i18n: I18n;
groupNameRef: {
current: string;
};
}) {
const { attribute, getClassName, i18n, groupNameRef } = props;
const { advancedMsg } = i18n;
if (attribute.group?.name !== groupNameRef.current) {
groupNameRef.current = attribute.group?.name ?? "";
if (groupNameRef.current !== "") {
assert(attribute.group !== undefined);
return (
<div
className={getClassName("kcFormGroupClass")}
{...Object.fromEntries(Object.entries(attribute.group.html5DataAnnotations).map(([key, value]) => [`data-${key}`, value]))}
>
{(() => {
const groupDisplayHeader = attribute.group.displayHeader ?? "";
const groupHeaderText = groupDisplayHeader !== "" ? advancedMsg(groupDisplayHeader) : attribute.group.name;
return (
<div className={getClassName("kcContentWrapperClass")}>
<label id={`header-${attribute.group.name}`} className={getClassName("kcFormGroupHeader")}>
{groupHeaderText}
</label>
</div>
);
})()}
{(() => {
const groupDisplayDescription = attribute.group.displayDescription ?? "";
if (groupDisplayDescription !== "") {
const groupDescriptionText = advancedMsg(groupDisplayDescription);
return (
<div className={getClassName("kcLabelWrapperClass")}>
<label id={`description-${attribute.group.name}`} className={getClassName("kcLabelClass")}>
{groupDescriptionText}
</label>
</div>
);
}
return null;
})()}
</div>
);
}
}
return null;
}
function FieldErrors(props: {
attribute: Attribute;
getClassName: UserProfileFormFieldsProps["getClassName"];
displayableErrors: FormFieldError[];
fieldIndex: number | undefined;
}) {
const { attribute, getClassName, fieldIndex } = props;
const displayableErrors = props.displayableErrors.filter(error => error.fieldIndex === fieldIndex);
if (displayableErrors.length === 0) {
return null;
}
return (
<span
id={`input-error-${attribute.name}${fieldIndex === undefined ? "" : `-${fieldIndex}`}`}
className={getClassName("kcInputErrorMessageClass")}
aria-live="polite"
>
{displayableErrors
.filter(error => error.fieldIndex === fieldIndex)
.map(({ errorMessage }, i, arr) => (
<Fragment key={i}>
<span key={i}>{errorMessage}</span>
{arr.length - 1 !== i && <br />}
</Fragment>
))}
</span>
);
}
type InputFiledByTypeProps = {
attribute: Attribute;
valueOrValues: string | string[];
displayableErrors: FormFieldError[];
formValidationDispatch: React.Dispatch<FormAction>;
getClassName: UserProfileFormFieldsProps["getClassName"];
i18n: I18n;
};
function InputFiledByType(props: InputFiledByTypeProps) {
const { attribute, valueOrValues } = props;
switch (attribute.annotations.inputType) {
case "textarea":
return <TextareaTag {...props} />;
case "select":
case "multiselect":
return <SelectTag {...props} />;
case "select-radiobuttons":
case "multiselect-checkboxes":
return <InputTagSelects {...props} />;
default: {
if (valueOrValues instanceof Array) {
return (
<>
{valueOrValues.map((...[, i]) => (
<InputTag key={i} {...props} fieldIndex={i} />
))}
</>
);
}
const inputNode = <InputTag {...props} fieldIndex={undefined} />;
if (attribute.name === "password" || attribute.name === "password-confirm") {
return (
<PasswordWrapper getClassName={props.getClassName} i18n={props.i18n} passwordInputId={attribute.name}>
{inputNode}
</PasswordWrapper>
);
}
return inputNode;
}
}
}
function PasswordWrapper(props: { getClassName: (classKey: ClassKey) => string; i18n: I18n; passwordInputId: string; children: JSX.Element }) {
const { getClassName, i18n, passwordInputId, children } = props;
const { msgStr } = i18n;
const [isPasswordRevealed, toggleIsPasswordRevealed] = useReducer((isPasswordRevealed: boolean) => !isPasswordRevealed, false);
useEffect(() => {
const passwordInputElement = document.getElementById(passwordInputId);
assert(passwordInputElement instanceof HTMLInputElement);
passwordInputElement.type = isPasswordRevealed ? "text" : "password";
}, [isPasswordRevealed]);
return (
<div className={getClassName("kcInputGroup")}>
{children}
<button
type="button"
className={getClassName("kcFormPasswordVisibilityButtonClass")}
aria-label={msgStr(isPasswordRevealed ? "hidePassword" : "showPassword")}
aria-controls={passwordInputId}
onClick={toggleIsPasswordRevealed}
>
<i
className={getClassName(isPasswordRevealed ? "kcFormPasswordVisibilityIconHide" : "kcFormPasswordVisibilityIconShow")}
aria-hidden
/>
</button>
</div>
);
}
function InputTag(props: InputFiledByTypeProps & { fieldIndex: number | undefined }) {
const { attribute, fieldIndex, getClassName, formValidationDispatch, valueOrValues, i18n, displayableErrors } = props;
return (
<>
<input
type={(() => {
const { inputType } = attribute.annotations;
if (inputType?.startsWith("html5-")) {
return inputType.slice(6);
}
return inputType ?? "text";
})()}
id={attribute.name}
name={attribute.name}
value={(() => {
if (fieldIndex !== undefined) {
assert(valueOrValues instanceof Array);
return valueOrValues[fieldIndex];
}
assert(typeof valueOrValues === "string");
return valueOrValues;
})()}
className={getClassName("kcInputClass")}
aria-invalid={displayableErrors.find(error => error.fieldIndex === fieldIndex) !== undefined}
disabled={attribute.readOnly}
autoComplete={attribute.autocomplete}
placeholder={attribute.annotations.inputTypePlaceholder}
pattern={attribute.annotations.inputTypePattern}
size={attribute.annotations.inputTypeSize === undefined ? undefined : parseInt(`${attribute.annotations.inputTypeSize}`)}
maxLength={
attribute.annotations.inputTypeMaxlength === undefined ? undefined : parseInt(`${attribute.annotations.inputTypeMaxlength}`)
}
minLength={
attribute.annotations.inputTypeMinlength === undefined ? undefined : parseInt(`${attribute.annotations.inputTypeMinlength}`)
}
max={attribute.annotations.inputTypeMax}
min={attribute.annotations.inputTypeMin}
step={attribute.annotations.inputTypeStep}
{...Object.fromEntries(Object.entries(attribute.html5DataAnnotations ?? {}).map(([key, value]) => [`data-${key}`, value]))}
onChange={event =>
formValidationDispatch({
action: "update",
name: attribute.name,
valueOrValues: (() => {
if (fieldIndex !== undefined) {
assert(valueOrValues instanceof Array);
return valueOrValues.map((value, i) => {
if (i === fieldIndex) {
return event.target.value;
}
return value;
});
}
return event.target.value;
})()
})
}
onBlur={() =>
props.formValidationDispatch({
action: "focus lost",
name: attribute.name,
fieldIndex: fieldIndex
})
}
/>
{(() => {
if (fieldIndex === undefined) {
return null;
}
assert(valueOrValues instanceof Array);
const values = valueOrValues;
return (
<>
<FieldErrors
attribute={attribute}
getClassName={getClassName}
displayableErrors={displayableErrors}
fieldIndex={fieldIndex}
/>
<AddRemoveButtonsMultiValuedAttribute
attribute={attribute}
values={values}
fieldIndex={fieldIndex}
dispatchFormAction={formValidationDispatch}
i18n={i18n}
/>
</>
);
})()}
</>
);
}
function AddRemoveButtonsMultiValuedAttribute(props: {
attribute: Attribute;
values: string[];
fieldIndex: number;
dispatchFormAction: React.Dispatch<Extract<FormAction, { action: "update" }>>;
i18n: I18n;
}) {
const { attribute, values, fieldIndex, dispatchFormAction, i18n } = props;
const { msg } = i18n;
const { hasAdd, hasRemove } = getButtonToDisplayForMultivaluedAttributeField({ attribute, values, fieldIndex });
const idPostfix = `-${attribute.name}-${fieldIndex + 1}`;
return (
<>
{hasRemove && (
<>
<button
id={`kc-remove${idPostfix}`}
type="button"
className="pf-c-button pf-m-inline pf-m-link"
onClick={() =>
dispatchFormAction({
action: "update",
name: attribute.name,
valueOrValues: values.filter((_, i) => i !== fieldIndex)
})
}
>
{msg("remove")}
</button>
{hasAdd ? <>&nbsp;|&nbsp;</> : null}
</>
)}
{hasAdd && (
<button
id={`kc-add${idPostfix}`}
type="button"
className="pf-c-button pf-m-inline pf-m-link"
onClick={() =>
dispatchFormAction({
action: "update",
name: attribute.name,
valueOrValues: [...values, ""]
})
}
>
{msg("addValue")}
</button>
)}
</>
);
}
function InputTagSelects(props: InputFiledByTypeProps) {
const { attribute, formValidationDispatch, getClassName, valueOrValues } = props;
const { advancedMsg } = props.i18n;
const { classDiv, classInput, classLabel, inputType } = (() => {
const { inputType } = attribute.annotations;
assert(inputType === "select-radiobuttons" || inputType === "multiselect-checkboxes");
switch (inputType) {
case "select-radiobuttons":
return {
inputType: "radio",
classDiv: getClassName("kcInputClassRadio"),
classInput: getClassName("kcInputClassRadioInput"),
classLabel: getClassName("kcInputClassRadioLabel")
};
case "multiselect-checkboxes":
return {
inputType: "checkbox",
classDiv: getClassName("kcInputClassCheckbox"),
classInput: getClassName("kcInputClassCheckboxInput"),
classLabel: getClassName("kcInputClassCheckboxLabel")
};
}
})();
const options = (() => {
walk: {
const { inputOptionsFromValidation } = attribute.annotations;
if (inputOptionsFromValidation === undefined) {
break walk;
}
const validator = (attribute.validators as Record<string, { options?: string[] }>)[inputOptionsFromValidation];
if (validator === undefined) {
break walk;
}
if (validator.options === undefined) {
break walk;
}
return validator.options;
}
return attribute.validators.options?.options ?? [];
})();
return (
<>
{options.map(option => (
<div key={option} className={classDiv}>
<input
type={inputType}
id={`${attribute.name}-${option}`}
name={attribute.name}
value={option}
className={classInput}
aria-invalid={props.displayableErrors.length !== 0}
disabled={attribute.readOnly}
checked={valueOrValues instanceof Array ? valueOrValues.includes(option) : valueOrValues === option}
onChange={event =>
formValidationDispatch({
action: "update",
name: attribute.name,
valueOrValues: (() => {
const isChecked = event.target.checked;
if (valueOrValues instanceof Array) {
const newValues = [...valueOrValues];
if (isChecked) {
newValues.push(option);
} else {
newValues.splice(newValues.indexOf(option), 1);
}
return newValues;
}
return event.target.checked ? option : "";
})()
})
}
onBlur={() =>
formValidationDispatch({
action: "focus lost",
name: attribute.name,
fieldIndex: undefined
})
}
/>
<label
htmlFor={`${attribute.name}-${option}`}
className={`${classLabel}${attribute.readOnly ? ` ${getClassName("kcInputClassRadioCheckboxLabelDisabled")}` : ""}`}
>
{advancedMsg(option)}
</label>
</div>
))}
</>
);
}
function TextareaTag(props: InputFiledByTypeProps) {
const { attribute, formValidationDispatch, getClassName, displayableErrors, valueOrValues } = props;
assert(typeof valueOrValues === "string");
const value = valueOrValues;
return (
<textarea
id={attribute.name}
name={attribute.name}
className={getClassName("kcInputClass")}
aria-invalid={displayableErrors.length !== 0}
disabled={attribute.readOnly}
cols={attribute.annotations.inputTypeCols === undefined ? undefined : parseInt(`${attribute.annotations.inputTypeCols}`)}
rows={attribute.annotations.inputTypeRows === undefined ? undefined : parseInt(`${attribute.annotations.inputTypeRows}`)}
maxLength={attribute.annotations.inputTypeMaxlength === undefined ? undefined : parseInt(`${attribute.annotations.inputTypeMaxlength}`)}
value={value}
onChange={event =>
formValidationDispatch({
action: "update",
name: attribute.name,
valueOrValues: event.target.value
})
}
onBlur={() =>
formValidationDispatch({
action: "focus lost",
name: attribute.name,
fieldIndex: undefined
})
}
/>
);
}
function SelectTag(props: InputFiledByTypeProps) {
const { attribute, formValidationDispatch, getClassName, displayableErrors, i18n, valueOrValues } = props;
const { advancedMsg } = i18n;
const isMultiple = attribute.annotations.inputType === "multiselect";
return (
<select
id={attribute.name}
name={attribute.name}
className={getClassName("kcInputClass")}
aria-invalid={displayableErrors.length !== 0}
disabled={attribute.readOnly}
multiple={isMultiple}
size={attribute.annotations.inputTypeSize === undefined ? undefined : parseInt(`${attribute.annotations.inputTypeSize}`)}
value={valueOrValues}
onChange={event =>
formValidationDispatch({
action: "update",
name: attribute.name,
valueOrValues: (() => {
if (isMultiple) {
return Array.from(event.target.selectedOptions).map(option => option.value);
}
return event.target.value;
})()
})
}
onBlur={() =>
formValidationDispatch({
action: "focus lost",
name: attribute.name,
fieldIndex: undefined
})
}
>
{!isMultiple && <option value=""></option>}
{(() => {
const options = (() => {
walk: {
const { inputOptionsFromValidation } = attribute.annotations;
if (inputOptionsFromValidation === undefined) {
break walk;
}
assert(typeof inputOptionsFromValidation === "string");
const validator = (attribute.validators as Record<string, { options?: string[] }>)[inputOptionsFromValidation];
if (validator === undefined) {
break walk;
}
if (validator.options === undefined) {
break walk;
}
return validator.options;
}
return attribute.validators.options?.options ?? [];
})();
return options.map(option => (
<option key={option} value={option}>
{(() => {
if (attribute.annotations.inputOptionLabels !== undefined) {
const { inputOptionLabels } = attribute.annotations;
return advancedMsg(inputOptionLabels[option] ?? option);
}
if (attribute.annotations.inputOptionLabelsI18nPrefix !== undefined) {
return advancedMsg(`${attribute.annotations.inputOptionLabelsI18nPrefix}.${option}`);
}
return option;
})()}
</option>
));
})()}
</select>
);
}

View file

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

View file

@ -1,24 +1,33 @@
/* eslint-disable react-refresh/only-export-components */
import { createRoot } from "react-dom/client";
import { StrictMode, lazy, Suspense } from "react";
//import { getKcContextMock } from "./login/PageStory";
//const kcContext = getKcContextMock({ pageId: "register.ftl", overrides: {} });
const { kcContext } = window;
const KcLoginThemeApp = lazy(() => import("./login/KcApp"));
const KcAccountThemeApp = lazy(() => import("./account/KcApp"));
let { kcContext } = window;
// NOTE: This is just to test a specific page when you run `yarn dev`
// however the recommended way to develope is to use the Storybook
if (kcContext === undefined) {
kcContext = (await import("./login/PageStory")).getKcContextMock({
pageId: "register.ftl"
});
}
createRoot(document.getElementById("root")!).render(
<StrictMode>
<Suspense>
{(() => {
switch (kcContext?.themeType) {
case "login": return <KcLoginThemeApp kcContext={kcContext} />;
case "account": return <KcAccountThemeApp kcContext={kcContext} />;
case undefined: return <h1>No Keycloak Context</h1>;
case "login":
return <KcLoginThemeApp kcContext={kcContext} />;
case "account":
return <KcAccountThemeApp kcContext={kcContext} />;
case undefined:
return <h1>No Keycloak Context</h1>;
}
})()}
</Suspense>
</StrictMode>
);

1
src/vite-env.d.ts vendored
View file

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

View file

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

View file

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