Compare commits

..

12 Commits

12 changed files with 147 additions and 94 deletions

View File

@ -1,6 +1,6 @@
{ {
"name": "keycloakify", "name": "keycloakify",
"version": "11.3.26", "version": "11.3.31",
"description": "Framework to create custom Keycloak UIs", "description": "Framework to create custom Keycloak UIs",
"repository": { "repository": {
"type": "git", "type": "git",

View File

@ -5,6 +5,9 @@ import { readThisNpmPackageVersion } from "./tools/readThisNpmPackageVersion";
import * as child_process from "child_process"; import * as child_process from "child_process";
import { assertNoPnpmDlx } from "./tools/assertNoPnpmDlx"; import { assertNoPnpmDlx } from "./tools/assertNoPnpmDlx";
import { getBuildContext } from "./shared/buildContext"; import { getBuildContext } from "./shared/buildContext";
import { SemVer } from "./tools/SemVer";
import { assert, is } from "tsafe/assert";
import chalk from "chalk";
type CliCommandOptions = { type CliCommandOptions = {
projectDirPath: string | undefined; projectDirPath: string | undefined;
@ -80,7 +83,7 @@ program
program program
.command<{ .command<{
port: number | undefined; port: number | undefined;
keycloakVersion: string | undefined; keycloakVersion: string | number | undefined;
realmJsonFilePath: string | undefined; realmJsonFilePath: string | undefined;
}>({ }>({
name: "start-keycloak", name: "start-keycloak",
@ -134,9 +137,50 @@ program
handler: async ({ projectDirPath, keycloakVersion, port, realmJsonFilePath }) => { handler: async ({ projectDirPath, keycloakVersion, port, realmJsonFilePath }) => {
const { command } = await import("./start-keycloak"); const { command } = await import("./start-keycloak");
validate_keycloak_version: {
if (keycloakVersion === undefined) {
break validate_keycloak_version;
}
const isValidVersion = (() => {
if (typeof keycloakVersion === "number") {
return false;
}
try {
SemVer.parse(keycloakVersion);
} catch {
return false;
}
return;
})();
if (isValidVersion) {
break validate_keycloak_version;
}
console.log(
chalk.red(
[
`Invalid Keycloak version: ${keycloakVersion}`,
"It should be a valid semver version example: 26.0.4"
].join(" ")
)
);
process.exit(1);
}
assert(is<string | undefined>(keycloakVersion));
await command({ await command({
buildContext: getBuildContext({ projectDirPath }), buildContext: getBuildContext({ projectDirPath }),
cliCommandOptions: { keycloakVersion, port, realmJsonFilePath } cliCommandOptions: {
keycloakVersion,
port,
realmJsonFilePath
}
}); });
} }
}); });
@ -201,7 +245,7 @@ program
.command({ .command({
name: "copy-keycloak-resources-to-public", name: "copy-keycloak-resources-to-public",
description: description:
"(Webpack/Create-React-App only) Copy Keycloak default theme resources to the public directory." "(Internal) Copy Keycloak default theme resources to the public directory."
}) })
.task({ .task({
skip, skip,

View File

@ -9,6 +9,16 @@ import { getIsPrettierAvailable, runPrettier } from "./tools/runPrettier";
export async function command(params: { buildContext: BuildContext }) { export async function command(params: { buildContext: BuildContext }) {
const { buildContext } = params; const { buildContext } = params;
run_copy_assets_to_public: {
if (buildContext.bundler !== "webpack") {
break run_copy_assets_to_public;
}
const { command } = await import("./copy-keycloak-resources-to-public");
await command({ buildContext });
}
const { hasBeenHandled } = maybeDelegateCommandToCustomHandler({ const { hasBeenHandled } = maybeDelegateCommandToCustomHandler({
commandName: "update-kc-gen", commandName: "update-kc-gen",
buildContext buildContext

View File

@ -33,7 +33,7 @@ export type KcContext =
| KcContext.LoginResetPassword | KcContext.LoginResetPassword
| KcContext.LoginVerifyEmail | KcContext.LoginVerifyEmail
| KcContext.Terms | KcContext.Terms
| KcContext.LoginDeviceVerifyUserCode | KcContext.LoginOauth2DeviceVerifyUserCode
| KcContext.LoginOauthGrant | KcContext.LoginOauthGrant
| KcContext.LoginOtp | KcContext.LoginOtp
| KcContext.LoginUsername | KcContext.LoginUsername
@ -277,7 +277,7 @@ export declare namespace KcContext {
__localizationRealmOverridesTermsText?: string; __localizationRealmOverridesTermsText?: string;
}; };
export type LoginDeviceVerifyUserCode = Common & { export type LoginOauth2DeviceVerifyUserCode = Common & {
pageId: "login-oauth2-device-verify-user-code.ftl"; pageId: "login-oauth2-device-verify-user-code.ftl";
url: { url: {
oauth2DeviceVerificationAction: string; oauth2DeviceVerificationAction: string;

View File

@ -290,7 +290,7 @@ export const kcContextMocks = [
...kcContextCommonMock, ...kcContextCommonMock,
pageId: "terms.ftl" pageId: "terms.ftl"
}), }),
id<KcContext.LoginDeviceVerifyUserCode>({ id<KcContext.LoginOauth2DeviceVerifyUserCode>({
...kcContextCommonMock, ...kcContextCommonMock,
pageId: "login-oauth2-device-verify-user-code.ftl", pageId: "login-oauth2-device-verify-user-code.ftl",
url: loginUrl url: loginUrl

View File

@ -11,7 +11,6 @@ export type TemplateProps<KcContext, I18n> = {
displayInfo?: boolean; displayInfo?: boolean;
displayMessage?: boolean; displayMessage?: boolean;
displayRequiredFields?: boolean; displayRequiredFields?: boolean;
showAnotherWayIfPresent?: boolean;
headerNode: ReactNode; headerNode: ReactNode;
socialProvidersNode?: ReactNode; socialProvidersNode?: ReactNode;
infoNode?: ReactNode; infoNode?: ReactNode;

View File

@ -1,5 +1,6 @@
import "keycloakify/tools/Object.fromEntries"; import "keycloakify/tools/Object.fromEntries";
import { assert, is } from "tsafe/assert"; import { assert, is } from "tsafe/assert";
import { extractLastParenthesisContent } from "keycloakify/tools/extractLastParenthesisContent";
import messages_defaultSet_fallbackLanguage from "../messages_defaultSet/en"; import messages_defaultSet_fallbackLanguage from "../messages_defaultSet/en";
import { fetchMessages_defaultSet } from "../messages_defaultSet"; import { fetchMessages_defaultSet } from "../messages_defaultSet";
import type { KcContext } from "../../KcContext"; import type { KcContext } from "../../KcContext";
@ -168,12 +169,10 @@ export function createGetI18n<
break from_server; break from_server;
} }
// cspell: disable-next-line const lastParenthesisContent = extractLastParenthesisContent(supportedEntry.label);
// from "Espagnol (Español)" we want to extract "Español"
const match = supportedEntry.label.match(/[^(]+\(([^)]+)\)/);
if (match !== null) { if (lastParenthesisContent !== undefined) {
return match[1]; return lastParenthesisContent;
} }
return supportedEntry.label; return supportedEntry.label;

View File

@ -47,11 +47,25 @@ export function createUseI18n<
function renderHtmlString(params: { htmlString: string; msgKey: string }): JSX.Element { function renderHtmlString(params: { htmlString: string; msgKey: string }): JSX.Element {
const { htmlString, msgKey } = params; const { htmlString, msgKey } = params;
const htmlString_sanitized = kcSanitize(htmlString);
const Element = (() => {
if (htmlString_sanitized.includes("<") && htmlString_sanitized.includes(">")) {
for (const tagName of ["div", "section", "article", "ul", "ol"]) {
if (htmlString_sanitized.includes(`<${tagName}`)) {
return "div";
}
}
}
return "span";
})();
return ( return (
<div <Element
data-kc-msg={msgKey} data-kc-msg={msgKey}
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: kcSanitize(htmlString) __html: htmlString_sanitized
}} }}
/> />
); );
@ -83,7 +97,7 @@ export function createUseI18n<
})(); })();
add_style: { add_style: {
const attributeName = "data-kc-i18n"; const attributeName = "data-kc-msg";
// Check if already exists in head // Check if already exists in head
if (document.querySelector(`style[${attributeName}]`) !== null) { if (document.querySelector(`style[${attributeName}]`) !== null) {
@ -92,7 +106,7 @@ export function createUseI18n<
const styleElement = document.createElement("style"); const styleElement = document.createElement("style");
styleElement.attributes.setNamedItem(document.createAttribute(attributeName)); styleElement.attributes.setNamedItem(document.createAttribute(attributeName));
styleElement.textContent = `[data-kc-msg] { display: inline-block; }`; styleElement.textContent = `div[${attributeName}] { display: inline-block; }`;
document.head.prepend(styleElement); document.head.prepend(styleElement);
} }

View File

@ -52,28 +52,26 @@ export default function LoginConfigTotp(props: PageProps<Extract<KcContext, { pa
</li> </li>
<li> <li>
<p>{msg("loginTotpManualStep3")}</p> <p>{msg("loginTotpManualStep3")}</p>
<p> <ul>
<ul> <li id="kc-totp-type">
<li id="kc-totp-type"> {msg("loginTotpType")}: {msg(`loginTotp.${totp.policy.type}`)}
{msg("loginTotpType")}: {msg(`loginTotp.${totp.policy.type}`)} </li>
<li id="kc-totp-algorithm">
{msg("loginTotpAlgorithm")}: {totp.policy.getAlgorithmKey()}
</li>
<li id="kc-totp-digits">
{msg("loginTotpDigits")}: {totp.policy.digits}
</li>
{totp.policy.type === "totp" ? (
<li id="kc-totp-period">
{msg("loginTotpInterval")}: {totp.policy.period}
</li> </li>
<li id="kc-totp-algorithm"> ) : (
{msg("loginTotpAlgorithm")}: {totp.policy.getAlgorithmKey()} <li id="kc-totp-counter">
{msg("loginTotpCounter")}: {totp.policy.initialCounter}
</li> </li>
<li id="kc-totp-digits"> )}
{msg("loginTotpDigits")}: {totp.policy.digits} </ul>
</li>
{totp.policy.type === "totp" ? (
<li id="kc-totp-period">
{msg("loginTotpInterval")}: {totp.policy.period}
</li>
) : (
<li id="kc-totp-counter">
{msg("loginTotpCounter")}: {totp.policy.initialCounter}
</li>
)}
</ul>
</p>
</li> </li>
</> </>
) : ( ) : (

View File

@ -0,0 +1,43 @@
/**
* "Hello (world)" => "world"
* "Hello (world) (foo)" => "foo"
* "Hello (world (foo))" => "world (foo)"
*/
export function extractLastParenthesisContent(str: string): string | undefined {
const chars: string[] = [];
for (const char of str) {
chars.push(char);
}
const extractedChars: string[] = [];
let openingCount = 0;
loop_through_char: for (let i = chars.length - 1; i >= 0; i--) {
const char = chars[i];
if (i === chars.length - 1) {
if (char !== ")") {
return undefined;
}
continue;
}
switch (char) {
case ")":
openingCount++;
break;
case "(":
if (openingCount === 0) {
return extractedChars.join("");
}
openingCount--;
break;
}
extractedChars.unshift(char);
}
return undefined;
}

View File

@ -17,6 +17,7 @@ export const Default: Story = {
render: () => ( render: () => (
<KcPageStory <KcPageStory
kcContext={{ kcContext={{
messageHeader: "Message header",
message: { message: {
summary: "Server info message" summary: "Server info message"
} }
@ -29,6 +30,7 @@ export const WithLinkBack: Story = {
render: () => ( render: () => (
<KcPageStory <KcPageStory
kcContext={{ kcContext={{
messageHeader: "Message header",
message: { message: {
summary: "Server message" summary: "Server message"
}, },
@ -42,6 +44,7 @@ export const WithRequiredActions: Story = {
render: () => ( render: () => (
<KcPageStory <KcPageStory
kcContext={{ kcContext={{
messageHeader: "Message header",
message: { message: {
summary: "Required actions: " summary: "Required actions: "
}, },
@ -55,42 +58,3 @@ export const WithRequiredActions: Story = {
/> />
) )
}; };
export const WithPageRedirect: Story = {
render: () => (
<KcPageStory
kcContext={{
message: { summary: "You will be redirected shortly." },
pageRedirectUri: "https://example.com"
}}
/>
)
};
export const WithoutClientBaseUrl: Story = {
render: () => (
<KcPageStory
kcContext={{
message: { summary: "No client base URL defined." },
client: { baseUrl: undefined }
}}
/>
)
};
export const WithMessageHeader: Story = {
render: () => (
<KcPageStory
kcContext={{
messageHeader: "Important Notice",
message: { summary: "This is an important message." }
}}
/>
)
};
export const WithAdvancedMessage: Story = {
render: () => (
<KcPageStory
kcContext={{
message: { summary: "Please take note of this <strong>important</strong> information." }
}}
/>
)
};

View File

@ -1,18 +0,0 @@
import React from "react";
import type { Meta, StoryObj } from "@storybook/react";
import { createKcPageStory } from "../KcPageStory";
const { KcPageStory } = createKcPageStory({ pageId: "login-device-verify-user-code.ftl" });
const meta = {
title: "login/login-device-verify-user-code.ftl",
component: KcPageStory
} satisfies Meta<typeof KcPageStory>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
render: () => <KcPageStory />
};