Migrate to Keycloakify v10
This commit is contained in:
parent
030836d534
commit
59008f5b87
|
@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
2
.github/workflows/ci.yaml
vendored
2
.github/workflows/ci.yaml
vendored
|
@ -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
6
.prettierignore
Normal file
|
@ -0,0 +1,6 @@
|
|||
node_modules/
|
||||
/dist/
|
||||
/dist_keycloak/
|
||||
/public/keycloak-resources/
|
||||
/.vscode/
|
||||
/.yarn_home/
|
24
.prettierrc.json
Normal file
24
.prettierrc.json
Normal 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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
|
@ -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"]
|
||||
};
|
||||
|
|
3
.storybook/preview-head.html
Normal file
3
.storybook/preview-head.html
Normal file
|
@ -0,0 +1,3 @@
|
|||
<script>
|
||||
console.log("Hello world");
|
||||
</script>
|
|
@ -5,10 +5,10 @@ const preview: Preview = {
|
|||
controls: {
|
||||
matchers: {
|
||||
color: /(background|color)$/i,
|
||||
date: /Date$/i,
|
||||
},
|
||||
},
|
||||
},
|
||||
date: /Date$/i
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default preview;
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -5,4 +5,7 @@ export type KcContextExtraProperties = {};
|
|||
|
||||
export type KcContextExtraPropertiesPerPage = {};
|
||||
|
||||
export type KcContext = ExtendKcContext<KcContextExtraProperties, KcContextExtraPropertiesPerPage>;
|
||||
export type KcContext = ExtendKcContext<
|
||||
KcContextExtraProperties,
|
||||
KcContextExtraPropertiesPerPage
|
||||
>;
|
||||
|
|
|
@ -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 };
|
||||
}
|
||||
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
19
src/account/pages/Account.stories.tsx
Normal file
19
src/account/pages/Account.stories.tsx
Normal 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 />
|
||||
};
|
29
src/account/pages/Password.stories.tsx
Normal file
29
src/account/pages/Password.stories.tsx
Normal 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" }
|
||||
}}
|
||||
/>
|
||||
)
|
||||
};
|
|
@ -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>
|
||||
|
|
|
@ -5,4 +5,7 @@ export type KcContextExtraProperties = {};
|
|||
|
||||
export type KcContextExtraPropertiesPerPage = {};
|
||||
|
||||
export type KcContext = ExtendKcContext<KcContextExtraProperties, KcContextExtraPropertiesPerPage>;
|
||||
export type KcContext = ExtendKcContext<
|
||||
KcContextExtraProperties,
|
||||
KcContextExtraPropertiesPerPage
|
||||
>;
|
||||
|
|
|
@ -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 };
|
||||
}
|
||||
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -1,699 +0,0 @@
|
|||
// Copy pasted from: https://github.com/keycloakify/keycloakify/blob/main/src/login/UserProfileFormFields.tsx
|
||||
|
||||
import { useEffect, useReducer, Fragment } from "react";
|
||||
import { assert } from "tsafe/assert";
|
||||
import type { ClassKey } from "keycloakify/login/TemplateProps";
|
||||
import {
|
||||
useUserProfileForm,
|
||||
getButtonToDisplayForMultivaluedAttributeField,
|
||||
type KcContextLike,
|
||||
type FormAction,
|
||||
type FormFieldError
|
||||
} from "keycloakify/login/lib/useUserProfileForm";
|
||||
import type { Attribute } from "keycloakify/login/KcContext";
|
||||
import type { I18n } from "./i18n";
|
||||
|
||||
export type UserProfileFormFieldsProps = {
|
||||
kcContext: KcContextLike;
|
||||
i18n: I18n;
|
||||
getClassName: (classKey: ClassKey) => string;
|
||||
onIsFormSubmittableValueChange: (isFormSubmittable: boolean) => void;
|
||||
BeforeField?: (props: BeforeAfterFieldProps) => JSX.Element | null;
|
||||
AfterField?: (props: BeforeAfterFieldProps) => JSX.Element | null;
|
||||
};
|
||||
|
||||
type BeforeAfterFieldProps = {
|
||||
attribute: Attribute;
|
||||
dispatchFormAction: React.Dispatch<FormAction>;
|
||||
displayableErrors: FormFieldError[];
|
||||
i18n: I18n;
|
||||
valueOrValues: string | string[];
|
||||
};
|
||||
|
||||
// NOTE: Enabled by default but it's a UX best practice to set it to false.
|
||||
const doMakeUserConfirmPassword = true;
|
||||
|
||||
export default function UserProfileFormFields(props: UserProfileFormFieldsProps) {
|
||||
const { kcContext, onIsFormSubmittableValueChange, i18n, getClassName, BeforeField, AfterField } = props;
|
||||
|
||||
const { advancedMsg } = i18n;
|
||||
|
||||
const {
|
||||
formState: { formFieldStates, isFormSubmittable },
|
||||
dispatchFormAction
|
||||
} = useUserProfileForm({
|
||||
kcContext,
|
||||
i18n,
|
||||
doMakeUserConfirmPassword
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
onIsFormSubmittableValueChange(isFormSubmittable);
|
||||
}, [isFormSubmittable]);
|
||||
|
||||
const groupNameRef = { current: "" };
|
||||
|
||||
return (
|
||||
<>
|
||||
{formFieldStates.map(({ attribute, displayableErrors, valueOrValues }) => {
|
||||
return (
|
||||
<Fragment key={attribute.name}>
|
||||
<GroupLabel attribute={attribute} getClassName={getClassName} i18n={i18n} groupNameRef={groupNameRef} />
|
||||
{BeforeField !== undefined && (
|
||||
<BeforeField
|
||||
attribute={attribute}
|
||||
dispatchFormAction={dispatchFormAction}
|
||||
displayableErrors={displayableErrors}
|
||||
i18n={i18n}
|
||||
valueOrValues={valueOrValues}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={getClassName("kcFormGroupClass")}
|
||||
style={{
|
||||
display: attribute.name === "password-confirm" && !doMakeUserConfirmPassword ? "none" : undefined
|
||||
}}
|
||||
>
|
||||
<div className={getClassName("kcLabelWrapperClass")}>
|
||||
<label htmlFor={attribute.name} className={getClassName("kcLabelClass")}>
|
||||
{advancedMsg(attribute.displayName ?? "")}
|
||||
</label>
|
||||
{attribute.required && <>*</>}
|
||||
</div>
|
||||
<div className={getClassName("kcInputWrapperClass")}>
|
||||
{attribute.annotations.inputHelperTextBefore !== undefined && (
|
||||
<div
|
||||
className={getClassName("kcInputHelperTextBeforeClass")}
|
||||
id={`form-help-text-before-${attribute.name}`}
|
||||
aria-live="polite"
|
||||
>
|
||||
{advancedMsg(attribute.annotations.inputHelperTextBefore)}
|
||||
</div>
|
||||
)}
|
||||
<InputFiledByType
|
||||
attribute={attribute}
|
||||
valueOrValues={valueOrValues}
|
||||
displayableErrors={displayableErrors}
|
||||
formValidationDispatch={dispatchFormAction}
|
||||
getClassName={getClassName}
|
||||
i18n={i18n}
|
||||
/>
|
||||
<FieldErrors
|
||||
attribute={attribute}
|
||||
getClassName={getClassName}
|
||||
displayableErrors={displayableErrors}
|
||||
fieldIndex={undefined}
|
||||
/>
|
||||
{attribute.annotations.inputHelperTextAfter !== undefined && (
|
||||
<div
|
||||
className={getClassName("kcInputHelperTextAfterClass")}
|
||||
id={`form-help-text-after-${attribute.name}`}
|
||||
aria-live="polite"
|
||||
>
|
||||
{advancedMsg(attribute.annotations.inputHelperTextAfter)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{AfterField !== undefined && (
|
||||
<AfterField
|
||||
attribute={attribute}
|
||||
dispatchFormAction={dispatchFormAction}
|
||||
displayableErrors={displayableErrors}
|
||||
i18n={i18n}
|
||||
valueOrValues={valueOrValues}
|
||||
/>
|
||||
)}
|
||||
{/* NOTE: Downloading of html5DataAnnotations scripts is done in the useUserProfileForm hook */}
|
||||
</div>
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function GroupLabel(props: {
|
||||
attribute: Attribute;
|
||||
getClassName: UserProfileFormFieldsProps["getClassName"];
|
||||
i18n: I18n;
|
||||
groupNameRef: {
|
||||
current: string;
|
||||
};
|
||||
}) {
|
||||
const { attribute, getClassName, i18n, groupNameRef } = props;
|
||||
|
||||
const { advancedMsg } = i18n;
|
||||
|
||||
if (attribute.group?.name !== groupNameRef.current) {
|
||||
groupNameRef.current = attribute.group?.name ?? "";
|
||||
|
||||
if (groupNameRef.current !== "") {
|
||||
assert(attribute.group !== undefined);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={getClassName("kcFormGroupClass")}
|
||||
{...Object.fromEntries(Object.entries(attribute.group.html5DataAnnotations).map(([key, value]) => [`data-${key}`, value]))}
|
||||
>
|
||||
{(() => {
|
||||
const groupDisplayHeader = attribute.group.displayHeader ?? "";
|
||||
const groupHeaderText = groupDisplayHeader !== "" ? advancedMsg(groupDisplayHeader) : attribute.group.name;
|
||||
|
||||
return (
|
||||
<div className={getClassName("kcContentWrapperClass")}>
|
||||
<label id={`header-${attribute.group.name}`} className={getClassName("kcFormGroupHeader")}>
|
||||
{groupHeaderText}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{(() => {
|
||||
const groupDisplayDescription = attribute.group.displayDescription ?? "";
|
||||
|
||||
if (groupDisplayDescription !== "") {
|
||||
const groupDescriptionText = advancedMsg(groupDisplayDescription);
|
||||
|
||||
return (
|
||||
<div className={getClassName("kcLabelWrapperClass")}>
|
||||
<label id={`description-${attribute.group.name}`} className={getClassName("kcLabelClass")}>
|
||||
{groupDescriptionText}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function FieldErrors(props: {
|
||||
attribute: Attribute;
|
||||
getClassName: UserProfileFormFieldsProps["getClassName"];
|
||||
displayableErrors: FormFieldError[];
|
||||
fieldIndex: number | undefined;
|
||||
}) {
|
||||
const { attribute, getClassName, fieldIndex } = props;
|
||||
|
||||
const displayableErrors = props.displayableErrors.filter(error => error.fieldIndex === fieldIndex);
|
||||
|
||||
if (displayableErrors.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
id={`input-error-${attribute.name}${fieldIndex === undefined ? "" : `-${fieldIndex}`}`}
|
||||
className={getClassName("kcInputErrorMessageClass")}
|
||||
aria-live="polite"
|
||||
>
|
||||
{displayableErrors
|
||||
.filter(error => error.fieldIndex === fieldIndex)
|
||||
.map(({ errorMessage }, i, arr) => (
|
||||
<Fragment key={i}>
|
||||
<span key={i}>{errorMessage}</span>
|
||||
{arr.length - 1 !== i && <br />}
|
||||
</Fragment>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
type InputFiledByTypeProps = {
|
||||
attribute: Attribute;
|
||||
valueOrValues: string | string[];
|
||||
displayableErrors: FormFieldError[];
|
||||
formValidationDispatch: React.Dispatch<FormAction>;
|
||||
getClassName: UserProfileFormFieldsProps["getClassName"];
|
||||
i18n: I18n;
|
||||
};
|
||||
|
||||
function InputFiledByType(props: InputFiledByTypeProps) {
|
||||
const { attribute, valueOrValues } = props;
|
||||
|
||||
switch (attribute.annotations.inputType) {
|
||||
case "textarea":
|
||||
return <TextareaTag {...props} />;
|
||||
case "select":
|
||||
case "multiselect":
|
||||
return <SelectTag {...props} />;
|
||||
case "select-radiobuttons":
|
||||
case "multiselect-checkboxes":
|
||||
return <InputTagSelects {...props} />;
|
||||
default: {
|
||||
if (valueOrValues instanceof Array) {
|
||||
return (
|
||||
<>
|
||||
{valueOrValues.map((...[, i]) => (
|
||||
<InputTag key={i} {...props} fieldIndex={i} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const inputNode = <InputTag {...props} fieldIndex={undefined} />;
|
||||
|
||||
if (attribute.name === "password" || attribute.name === "password-confirm") {
|
||||
return (
|
||||
<PasswordWrapper getClassName={props.getClassName} i18n={props.i18n} passwordInputId={attribute.name}>
|
||||
{inputNode}
|
||||
</PasswordWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
return inputNode;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function PasswordWrapper(props: { getClassName: (classKey: ClassKey) => string; i18n: I18n; passwordInputId: string; children: JSX.Element }) {
|
||||
const { getClassName, i18n, passwordInputId, children } = props;
|
||||
|
||||
const { msgStr } = i18n;
|
||||
|
||||
const [isPasswordRevealed, toggleIsPasswordRevealed] = useReducer((isPasswordRevealed: boolean) => !isPasswordRevealed, false);
|
||||
|
||||
useEffect(() => {
|
||||
const passwordInputElement = document.getElementById(passwordInputId);
|
||||
|
||||
assert(passwordInputElement instanceof HTMLInputElement);
|
||||
|
||||
passwordInputElement.type = isPasswordRevealed ? "text" : "password";
|
||||
}, [isPasswordRevealed]);
|
||||
|
||||
return (
|
||||
<div className={getClassName("kcInputGroup")}>
|
||||
{children}
|
||||
<button
|
||||
type="button"
|
||||
className={getClassName("kcFormPasswordVisibilityButtonClass")}
|
||||
aria-label={msgStr(isPasswordRevealed ? "hidePassword" : "showPassword")}
|
||||
aria-controls={passwordInputId}
|
||||
onClick={toggleIsPasswordRevealed}
|
||||
>
|
||||
<i
|
||||
className={getClassName(isPasswordRevealed ? "kcFormPasswordVisibilityIconHide" : "kcFormPasswordVisibilityIconShow")}
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InputTag(props: InputFiledByTypeProps & { fieldIndex: number | undefined }) {
|
||||
const { attribute, fieldIndex, getClassName, formValidationDispatch, valueOrValues, i18n, displayableErrors } = props;
|
||||
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
type={(() => {
|
||||
const { inputType } = attribute.annotations;
|
||||
|
||||
if (inputType?.startsWith("html5-")) {
|
||||
return inputType.slice(6);
|
||||
}
|
||||
|
||||
return inputType ?? "text";
|
||||
})()}
|
||||
id={attribute.name}
|
||||
name={attribute.name}
|
||||
value={(() => {
|
||||
if (fieldIndex !== undefined) {
|
||||
assert(valueOrValues instanceof Array);
|
||||
return valueOrValues[fieldIndex];
|
||||
}
|
||||
|
||||
assert(typeof valueOrValues === "string");
|
||||
|
||||
return valueOrValues;
|
||||
})()}
|
||||
className={getClassName("kcInputClass")}
|
||||
aria-invalid={displayableErrors.find(error => error.fieldIndex === fieldIndex) !== undefined}
|
||||
disabled={attribute.readOnly}
|
||||
autoComplete={attribute.autocomplete}
|
||||
placeholder={attribute.annotations.inputTypePlaceholder}
|
||||
pattern={attribute.annotations.inputTypePattern}
|
||||
size={attribute.annotations.inputTypeSize === undefined ? undefined : parseInt(`${attribute.annotations.inputTypeSize}`)}
|
||||
maxLength={
|
||||
attribute.annotations.inputTypeMaxlength === undefined ? undefined : parseInt(`${attribute.annotations.inputTypeMaxlength}`)
|
||||
}
|
||||
minLength={
|
||||
attribute.annotations.inputTypeMinlength === undefined ? undefined : parseInt(`${attribute.annotations.inputTypeMinlength}`)
|
||||
}
|
||||
max={attribute.annotations.inputTypeMax}
|
||||
min={attribute.annotations.inputTypeMin}
|
||||
step={attribute.annotations.inputTypeStep}
|
||||
{...Object.fromEntries(Object.entries(attribute.html5DataAnnotations ?? {}).map(([key, value]) => [`data-${key}`, value]))}
|
||||
onChange={event =>
|
||||
formValidationDispatch({
|
||||
action: "update",
|
||||
name: attribute.name,
|
||||
valueOrValues: (() => {
|
||||
if (fieldIndex !== undefined) {
|
||||
assert(valueOrValues instanceof Array);
|
||||
|
||||
return valueOrValues.map((value, i) => {
|
||||
if (i === fieldIndex) {
|
||||
return event.target.value;
|
||||
}
|
||||
|
||||
return value;
|
||||
});
|
||||
}
|
||||
|
||||
return event.target.value;
|
||||
})()
|
||||
})
|
||||
}
|
||||
onBlur={() =>
|
||||
props.formValidationDispatch({
|
||||
action: "focus lost",
|
||||
name: attribute.name,
|
||||
fieldIndex: fieldIndex
|
||||
})
|
||||
}
|
||||
/>
|
||||
{(() => {
|
||||
if (fieldIndex === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
assert(valueOrValues instanceof Array);
|
||||
|
||||
const values = valueOrValues;
|
||||
|
||||
return (
|
||||
<>
|
||||
<FieldErrors
|
||||
attribute={attribute}
|
||||
getClassName={getClassName}
|
||||
displayableErrors={displayableErrors}
|
||||
fieldIndex={fieldIndex}
|
||||
/>
|
||||
<AddRemoveButtonsMultiValuedAttribute
|
||||
attribute={attribute}
|
||||
values={values}
|
||||
fieldIndex={fieldIndex}
|
||||
dispatchFormAction={formValidationDispatch}
|
||||
i18n={i18n}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AddRemoveButtonsMultiValuedAttribute(props: {
|
||||
attribute: Attribute;
|
||||
values: string[];
|
||||
fieldIndex: number;
|
||||
dispatchFormAction: React.Dispatch<Extract<FormAction, { action: "update" }>>;
|
||||
i18n: I18n;
|
||||
}) {
|
||||
const { attribute, values, fieldIndex, dispatchFormAction, i18n } = props;
|
||||
|
||||
const { msg } = i18n;
|
||||
|
||||
const { hasAdd, hasRemove } = getButtonToDisplayForMultivaluedAttributeField({ attribute, values, fieldIndex });
|
||||
|
||||
const idPostfix = `-${attribute.name}-${fieldIndex + 1}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
{hasRemove && (
|
||||
<>
|
||||
<button
|
||||
id={`kc-remove${idPostfix}`}
|
||||
type="button"
|
||||
className="pf-c-button pf-m-inline pf-m-link"
|
||||
onClick={() =>
|
||||
dispatchFormAction({
|
||||
action: "update",
|
||||
name: attribute.name,
|
||||
valueOrValues: values.filter((_, i) => i !== fieldIndex)
|
||||
})
|
||||
}
|
||||
>
|
||||
{msg("remove")}
|
||||
</button>
|
||||
{hasAdd ? <> | </> : null}
|
||||
</>
|
||||
)}
|
||||
{hasAdd && (
|
||||
<button
|
||||
id={`kc-add${idPostfix}`}
|
||||
type="button"
|
||||
className="pf-c-button pf-m-inline pf-m-link"
|
||||
onClick={() =>
|
||||
dispatchFormAction({
|
||||
action: "update",
|
||||
name: attribute.name,
|
||||
valueOrValues: [...values, ""]
|
||||
})
|
||||
}
|
||||
>
|
||||
{msg("addValue")}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function InputTagSelects(props: InputFiledByTypeProps) {
|
||||
const { attribute, formValidationDispatch, getClassName, valueOrValues } = props;
|
||||
|
||||
const { advancedMsg } = props.i18n;
|
||||
|
||||
const { classDiv, classInput, classLabel, inputType } = (() => {
|
||||
const { inputType } = attribute.annotations;
|
||||
|
||||
assert(inputType === "select-radiobuttons" || inputType === "multiselect-checkboxes");
|
||||
|
||||
switch (inputType) {
|
||||
case "select-radiobuttons":
|
||||
return {
|
||||
inputType: "radio",
|
||||
classDiv: getClassName("kcInputClassRadio"),
|
||||
classInput: getClassName("kcInputClassRadioInput"),
|
||||
classLabel: getClassName("kcInputClassRadioLabel")
|
||||
};
|
||||
case "multiselect-checkboxes":
|
||||
return {
|
||||
inputType: "checkbox",
|
||||
classDiv: getClassName("kcInputClassCheckbox"),
|
||||
classInput: getClassName("kcInputClassCheckboxInput"),
|
||||
classLabel: getClassName("kcInputClassCheckboxLabel")
|
||||
};
|
||||
}
|
||||
})();
|
||||
|
||||
const options = (() => {
|
||||
walk: {
|
||||
const { inputOptionsFromValidation } = attribute.annotations;
|
||||
|
||||
if (inputOptionsFromValidation === undefined) {
|
||||
break walk;
|
||||
}
|
||||
|
||||
const validator = (attribute.validators as Record<string, { options?: string[] }>)[inputOptionsFromValidation];
|
||||
|
||||
if (validator === undefined) {
|
||||
break walk;
|
||||
}
|
||||
|
||||
if (validator.options === undefined) {
|
||||
break walk;
|
||||
}
|
||||
|
||||
return validator.options;
|
||||
}
|
||||
|
||||
return attribute.validators.options?.options ?? [];
|
||||
})();
|
||||
|
||||
return (
|
||||
<>
|
||||
{options.map(option => (
|
||||
<div key={option} className={classDiv}>
|
||||
<input
|
||||
type={inputType}
|
||||
id={`${attribute.name}-${option}`}
|
||||
name={attribute.name}
|
||||
value={option}
|
||||
className={classInput}
|
||||
aria-invalid={props.displayableErrors.length !== 0}
|
||||
disabled={attribute.readOnly}
|
||||
checked={valueOrValues instanceof Array ? valueOrValues.includes(option) : valueOrValues === option}
|
||||
onChange={event =>
|
||||
formValidationDispatch({
|
||||
action: "update",
|
||||
name: attribute.name,
|
||||
valueOrValues: (() => {
|
||||
const isChecked = event.target.checked;
|
||||
|
||||
if (valueOrValues instanceof Array) {
|
||||
const newValues = [...valueOrValues];
|
||||
|
||||
if (isChecked) {
|
||||
newValues.push(option);
|
||||
} else {
|
||||
newValues.splice(newValues.indexOf(option), 1);
|
||||
}
|
||||
|
||||
return newValues;
|
||||
}
|
||||
|
||||
return event.target.checked ? option : "";
|
||||
})()
|
||||
})
|
||||
}
|
||||
onBlur={() =>
|
||||
formValidationDispatch({
|
||||
action: "focus lost",
|
||||
name: attribute.name,
|
||||
fieldIndex: undefined
|
||||
})
|
||||
}
|
||||
/>
|
||||
<label
|
||||
htmlFor={`${attribute.name}-${option}`}
|
||||
className={`${classLabel}${attribute.readOnly ? ` ${getClassName("kcInputClassRadioCheckboxLabelDisabled")}` : ""}`}
|
||||
>
|
||||
{advancedMsg(option)}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TextareaTag(props: InputFiledByTypeProps) {
|
||||
const { attribute, formValidationDispatch, getClassName, displayableErrors, valueOrValues } = props;
|
||||
|
||||
assert(typeof valueOrValues === "string");
|
||||
|
||||
const value = valueOrValues;
|
||||
|
||||
return (
|
||||
<textarea
|
||||
id={attribute.name}
|
||||
name={attribute.name}
|
||||
className={getClassName("kcInputClass")}
|
||||
aria-invalid={displayableErrors.length !== 0}
|
||||
disabled={attribute.readOnly}
|
||||
cols={attribute.annotations.inputTypeCols === undefined ? undefined : parseInt(`${attribute.annotations.inputTypeCols}`)}
|
||||
rows={attribute.annotations.inputTypeRows === undefined ? undefined : parseInt(`${attribute.annotations.inputTypeRows}`)}
|
||||
maxLength={attribute.annotations.inputTypeMaxlength === undefined ? undefined : parseInt(`${attribute.annotations.inputTypeMaxlength}`)}
|
||||
value={value}
|
||||
onChange={event =>
|
||||
formValidationDispatch({
|
||||
action: "update",
|
||||
name: attribute.name,
|
||||
valueOrValues: event.target.value
|
||||
})
|
||||
}
|
||||
onBlur={() =>
|
||||
formValidationDispatch({
|
||||
action: "focus lost",
|
||||
name: attribute.name,
|
||||
fieldIndex: undefined
|
||||
})
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectTag(props: InputFiledByTypeProps) {
|
||||
const { attribute, formValidationDispatch, getClassName, displayableErrors, i18n, valueOrValues } = props;
|
||||
|
||||
const { advancedMsg } = i18n;
|
||||
|
||||
const isMultiple = attribute.annotations.inputType === "multiselect";
|
||||
|
||||
return (
|
||||
<select
|
||||
id={attribute.name}
|
||||
name={attribute.name}
|
||||
className={getClassName("kcInputClass")}
|
||||
aria-invalid={displayableErrors.length !== 0}
|
||||
disabled={attribute.readOnly}
|
||||
multiple={isMultiple}
|
||||
size={attribute.annotations.inputTypeSize === undefined ? undefined : parseInt(`${attribute.annotations.inputTypeSize}`)}
|
||||
value={valueOrValues}
|
||||
onChange={event =>
|
||||
formValidationDispatch({
|
||||
action: "update",
|
||||
name: attribute.name,
|
||||
valueOrValues: (() => {
|
||||
if (isMultiple) {
|
||||
return Array.from(event.target.selectedOptions).map(option => option.value);
|
||||
}
|
||||
|
||||
return event.target.value;
|
||||
})()
|
||||
})
|
||||
}
|
||||
onBlur={() =>
|
||||
formValidationDispatch({
|
||||
action: "focus lost",
|
||||
name: attribute.name,
|
||||
fieldIndex: undefined
|
||||
})
|
||||
}
|
||||
>
|
||||
{!isMultiple && <option value=""></option>}
|
||||
{(() => {
|
||||
const options = (() => {
|
||||
walk: {
|
||||
const { inputOptionsFromValidation } = attribute.annotations;
|
||||
|
||||
if (inputOptionsFromValidation === undefined) {
|
||||
break walk;
|
||||
}
|
||||
|
||||
assert(typeof inputOptionsFromValidation === "string");
|
||||
|
||||
const validator = (attribute.validators as Record<string, { options?: string[] }>)[inputOptionsFromValidation];
|
||||
|
||||
if (validator === undefined) {
|
||||
break walk;
|
||||
}
|
||||
|
||||
if (validator.options === undefined) {
|
||||
break walk;
|
||||
}
|
||||
|
||||
return validator.options;
|
||||
}
|
||||
|
||||
return attribute.validators.options?.options ?? [];
|
||||
})();
|
||||
|
||||
return options.map(option => (
|
||||
<option key={option} value={option}>
|
||||
{(() => {
|
||||
if (attribute.annotations.inputOptionLabels !== undefined) {
|
||||
const { inputOptionLabels } = attribute.annotations;
|
||||
|
||||
return advancedMsg(inputOptionLabels[option] ?? option);
|
||||
}
|
||||
|
||||
if (attribute.annotations.inputOptionLabelsI18nPrefix !== undefined) {
|
||||
return advancedMsg(`${attribute.annotations.inputOptionLabelsI18nPrefix}.${option}`);
|
||||
}
|
||||
|
||||
return option;
|
||||
})()}
|
||||
</option>
|
||||
));
|
||||
})()}
|
||||
</select>
|
||||
);
|
||||
}
|
|
@ -1,4 +1,3 @@
|
|||
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { createPageStory } from "../PageStory";
|
||||
|
||||
|
|
23
src/main.tsx
23
src/main.tsx
|
@ -1,24 +1,33 @@
|
|||
/* eslint-disable react-refresh/only-export-components */
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { StrictMode, lazy, Suspense } from "react";
|
||||
//import { getKcContextMock } from "./login/PageStory";
|
||||
//const kcContext = getKcContextMock({ pageId: "register.ftl", overrides: {} });
|
||||
const { kcContext } = window;
|
||||
|
||||
const KcLoginThemeApp = lazy(() => import("./login/KcApp"));
|
||||
const KcAccountThemeApp = lazy(() => import("./account/KcApp"));
|
||||
|
||||
let { kcContext } = window;
|
||||
|
||||
// NOTE: This is just to test a specific page when you run `yarn dev`
|
||||
// however the recommended way to develope is to use the Storybook
|
||||
if (kcContext === undefined) {
|
||||
kcContext = (await import("./login/PageStory")).getKcContextMock({
|
||||
pageId: "register.ftl"
|
||||
});
|
||||
}
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<Suspense>
|
||||
{(() => {
|
||||
switch (kcContext?.themeType) {
|
||||
case "login": return <KcLoginThemeApp kcContext={kcContext} />;
|
||||
case "account": return <KcAccountThemeApp kcContext={kcContext} />;
|
||||
case undefined: return <h1>No Keycloak Context</h1>;
|
||||
case "login":
|
||||
return <KcLoginThemeApp kcContext={kcContext} />;
|
||||
case "account":
|
||||
return <KcAccountThemeApp kcContext={kcContext} />;
|
||||
case undefined:
|
||||
return <h1>No Keycloak Context</h1>;
|
||||
}
|
||||
})()}
|
||||
</Suspense>
|
||||
</StrictMode>
|
||||
);
|
||||
|
||||
|
|
1
src/vite-env.d.ts
vendored
1
src/vite-env.d.ts
vendored
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
}
|
||||
})
|
||||
});
|
||||
|
|
13
yarn.lock
13
yarn.lock
|
@ -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"
|
||||
|
|
Loading…
Reference in a new issue