Compare commits

..

2 Commits

Author SHA1 Message Date
2e017a8d2d Bump version 2023-04-17 01:41:45 +02:00
86702d8a73 Avoid deprecating getKcContext 2023-04-17 01:41:30 +02:00
91 changed files with 613 additions and 2636 deletions

View File

@ -131,24 +131,6 @@
"contributions": [
"code"
]
},
{
"login": "thosil",
"name": "Thomas Silvestre",
"avatar_url": "https://avatars.githubusercontent.com/u/1140574?v=4",
"profile": "https://www.gravitysoftware.be",
"contributions": [
"code"
]
},
{
"login": "satanshiro",
"name": "satanshiro",
"avatar_url": "https://avatars.githubusercontent.com/u/38865738?v=4",
"profile": "https://github.com/satanshiro",
"contributions": [
"code"
]
}
],
"contributorsPerLine": 7,

View File

@ -44,7 +44,7 @@ jobs:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: '18'
node-version: '16'
- uses: bahmutov/npm-install@v1
- run: yarn build-storybook -o ./build_storybook
- run: git remote set-url origin https://git:${GITHUB_TOKEN}@github.com/${{github.repository}}.git

4
.gitignore vendored
View File

@ -53,9 +53,5 @@ jspm_packages
# VS Code devcontainers
.devcontainer
/.yarn
/.yarnrc.yml
/stories/assets/fonts/
/build_storybook/
/storybook-static/

View File

@ -8,8 +8,8 @@ node_modules/
/.vscode/
/src/login/i18n/baseMessages/
/src/account/i18n/baseMessages/
# Test Build Directories
/dist_test
/sample_react_project/
/sample_custom_react_project/
/keycloakify_starter_test/
/.storybook/static/keycloak-resources/
/keycloakify_starter_test/

View File

@ -3,9 +3,8 @@ import React from "react";
import { DocsContainer as BaseContainer } from "@storybook/addon-docs";
import { useDarkMode } from "storybook-dark-mode";
import { darkTheme, lightTheme } from "./customTheme";
import "./static/fonts/WorkSans/font.css";
export function DocsContainer({ children, context }) {
export const DocsContainer = ({ children, context }) => {
const isStorybookUiDark = useDarkMode();
const theme = isStorybookUiDark ? darkTheme : lightTheme;
@ -16,7 +15,7 @@ export function DocsContainer({ children, context }) {
<>
<style>{`
body {
padding: 0 !important;
padding: 0 !important,
background-color: ${backgroundColor};
}
@ -58,19 +57,4 @@ export function DocsContainer({ children, context }) {
</BaseContainer>
</>
);
}
export function CanvasContainer({ children }) {
return (
<>
<style>{`
body {
padding: 0 !important;
}
`}</style>
{children}
</>
);
}
};

View File

@ -1,6 +1,8 @@
module.exports = {
"stories": [
"../stories/**/*.stories.@(ts|tsx|mdx)"
"../stories/*.stories.mdx",
"../stories/*.stories.@(ts|tsx)",
"../stories/**/*.stories.@(ts|tsx)"
],
"addons": [
"@storybook/addon-links",

View File

@ -1,6 +0,0 @@
import { addons } from '@storybook/addons';
addons.setConfig({
"selectedPanel": 'storybook/a11y/panel',
"showPanel": false,
});

View File

@ -1,5 +1,5 @@
import { darkTheme, lightTheme } from "./customTheme";
import { DocsContainer, CanvasContainer } from "./Containers";
import { DocsContainer } from "./DocsContainer";
export const parameters = {
"actions": { "argTypesRegex": "^on[A-Z].*" },
@ -17,12 +17,6 @@ export const parameters = {
"docs": {
"container": DocsContainer
},
"controls": {
"disable": true,
},
"actions": {
"disable": true
},
"viewport": {
"viewports": {
"1440p": {
@ -74,7 +68,7 @@ export const parameters = {
"height": "844px",
},
},
"iphone5se": {
"iphone5se":{
"name": "Iphone 5/SE",
"styles": {
"width": "320px",
@ -103,23 +97,16 @@ export const parameters = {
},
};
export const decorators = [
(Story) => (
<CanvasContainer>
<Story />
</CanvasContainer>
),
];
const { getHardCodedWeight } = (() => {
const orderedPagesPrefix = [
"Introduction",
"login/login.ftl",
"login/register-user-profile.ftl",
"login/register.ftl",
"login/terms.ftl",
"login/error.ftl",
//"components/Header",
//"components/Footer",
"components/Alert",
"components/Tabs",
"components/Stepper",
"components/Button",
];
function getHardCodedWeight(kind) {

View File

@ -1 +1 @@
storybook.keycloakify.dev
react-dsfr-components.etalab.studio

View File

@ -20,15 +20,12 @@
<a href="https://github.com/thomasdarimont/awesome-keycloak">
<img src="https://awesome.re/mentioned-badge.svg"/>
</a>
<a href="https://discord.gg/rBzsYtUn">
<img src="https://img.shields.io/discord/1097708346976505977"/>
</a>
<p align="center">
<a href="https://www.keycloakify.dev">Home</a>
-
<a href="https://docs.keycloakify.dev">Documentation</a>
-
<a href="https://storybook.keycloakify.dev">Storybook</a>
<a href="https://storybook.keycloakify.dev/storybook">Storybook</a>
-
<a href="https://github.com/codegouvfr/keycloakify-starter">Starter project</a>
</p>
@ -39,41 +36,27 @@
<img src="https://user-images.githubusercontent.com/6702424/110260457-a1c3d380-7fac-11eb-853a-80459b65626b.png">
</p>
> Whether or not React is your preferred framework, Keycloakify
> offers a solid option for building Keycloak themes.
> It's not just a convenient way to create a Keycloak theme
> when using React; it's a well-regarded solution that many
> developers appreciate.
## Sponsor 👼
We are exclusively sponsored by [Cloud IAM](https://cloud-iam.com/?mtm_campaign=keycloakify-deal&mtm_source=keycloakify-github), a French company offering Keycloak as a service.
We are exclusively sponsored by [Cloud IAM](https://www.cloud-iam.com), a French company offering Keycloak as a service.
Their dedicated support helps us continue the development and maintenance of this project.
[Cloud IAM](https://cloud-iam.com/?mtm_campaign=keycloakify-deal&mtm_source=keycloakify-github) provides the following services:
[Cloud IAM](https://www.cloud-iam.com/) provides the following services:
- Simplify and secure your Keycloak Identity and Access Management. Keycloak as a Service.
- Perfectly configured and optimized Keycloak IAM, ready in seconds.
- Custom theme building for your brand using Keycloakify.
<div align="center">
![Logo Dark](https://user-images.githubusercontent.com/6702424/234135797-c84d0a90-0526-43e5-a186-70cbebdeb278.png#gh-dark-mode-only)
</div>
<div align="center">
![Logo Light](https://user-images.githubusercontent.com/6702424/234135799-68684c33-4ec5-48d4-8763-0f3922c86643.png#gh-light-mode-only)
</div>
<p align="center">
<i>Checkout <a href="https://cloud-iam.com/?mtm_campaign=keycloakify-deal&mtm_source=keycloakify-github">Cloud IAM</a> and use promo code <code>keycloakify5</code></i>
<a href="https://www.cloud-iam.com/">
<img src="https://user-images.githubusercontent.com/6702424/232165752-17134e68-4a55-4d6e-8672-e9132ecac5d5.svg" alt="Cloud IAM Logo" width="200"/>
</a>
<br/>
<i>Use promo code <code>keycloakify</code> </i>
<br/>
<i>5% of your annual subscription will be donated to us, and you'll get 5% off too.</i>
</p>
Thank you, [Cloud IAM](https://cloud-iam.com/?mtm_campaign=keycloakify-deal&mtm_source=keycloakify-github), for your support!
Thank you, [Cloud IAM](https://www.cloud-iam.com/), for your support!
## Contributors ✨
@ -100,11 +83,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
<td align="center" valign="top" width="14.28%"><a href="https://github.com/lazToum"><img src="https://avatars.githubusercontent.com/u/4764837?v=4?s=100" width="100px;" alt="Lazaros Toumanidis"/><br /><sub><b>Lazaros Toumanidis</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=lazToum" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/marcmrf"><img src="https://avatars.githubusercontent.com/u/9928519?v=4?s=100" width="100px;" alt="Marc"/><br /><sub><b>Marc</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=marcmrf" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://kasir-barati.github.io"><img src="https://avatars.githubusercontent.com/u/73785723?v=4?s=100" width="100px;" alt="Kasir Barati"/><br /><sub><b>Kasir Barati</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=kasir-barati" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/asashay"><img src="https://avatars.githubusercontent.com/u/10714670?v=4?s=100" width="100px;" alt="Alex Oliynyk"/><br /><sub><b>Alex Oliynyk</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=asashay" title="Code">💻</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://www.gravitysoftware.be"><img src="https://avatars.githubusercontent.com/u/1140574?v=4?s=100" width="100px;" alt="Thomas Silvestre"/><br /><sub><b>Thomas Silvestre</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=thosil" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/satanshiro"><img src="https://avatars.githubusercontent.com/u/38865738?v=4?s=100" width="100px;" alt="satanshiro"/><br /><sub><b>satanshiro</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=satanshiro" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/asashay"><img src="https://avatars.githubusercontent.com/u/10714670?v=4?s=100" width="100px;" alt="Alex Oliynyk"/><br /><sub><b>Alex Oliynyk</b></sub></a><br /> <a href="https://github.com/keycloakify/keycloakify/commits?author=asashay" title="Code">💻</a></td>
</tr>
</tbody>
</table>
@ -114,18 +93,9 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
<!-- ALL-CONTRIBUTORS-LIST:END -->
# Changelog highlights
## Changelog highlights
## 7.9
- Separate script for copying the default theme static assets to the public directory.
Theses assets are only needed for testing your theme locally in Storybook or with a `mockPageId`.
You are now expected to have a `"prepare": "copy-keycloak-resources-to-public",` in your package.json scripts.
This script will create `public/keycloak-assets` when you run `yarn install` (If you are using another package manager
like `pnpm` makes sure that `"prepare"` is actually ran.)
[See the updated starter](https://github.com/keycloakify/keycloakify-starter/blob/94532fcf10bf8b19e0873be8575fd28a8958a806/package.json#L11). `public/keycloak-assets` shouldn't be tracked by GIT and is automatically ignored.
## 7.7
## 7.7
- Better storybook support, see [the starter project](https://github.com/keycloakify/keycloakify-starter).

View File

@ -1,6 +1,6 @@
{
"name": "keycloakify",
"version": "7.11.9",
"version": "7.7.1",
"description": "Create Keycloak themes using React",
"repository": {
"type": "git",
@ -9,9 +9,9 @@
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"prepare": "yarn generate-i18n-messages",
"build": "rimraf dist/ && tsc -p src/bin && tsc -p src && tsc-alias -p src/tsconfig.json && yarn grant-exec-perms && yarn copy-files dist/",
"watch-in-starter": "yarn build && yarn link-in-starter && (concurrently \"tsc -p src -w\" \"tsc-alias -p src/tsconfig.json\" \"tsc -p src/bin -w\")",
"prepare": "yarn generate-i18n-messages && yarn copy-fonts",
"build": "rimraf dist/ && tsc -p src/bin && tsc -p src/tsconfig.json && tsc-alias -p src/tsconfig.json && yarn grant-exec-perms && yarn copy-files dist/",
"build:watch": "tsc -p src/tsconfig.json && (concurrently \"tsc -p src/tsconfig.json -w\" \"tsc-alias -p src/tsconfig.json\")",
"generate:json-schema": "ts-node scripts/generate-json-schema.ts",
"grant-exec-perms": "node dist/bin/tools/grant-exec-perms.js",
"copy-files": "copyfiles -u 1 src/**/*.ftl",
@ -24,16 +24,16 @@
"generate-i18n-messages": "ts-node --skipProject scripts/generate-i18n-messages.ts",
"link-in-app": "ts-node --skipProject scripts/link-in-app.ts",
"link-in-starter": "yarn link-in-app keycloakify-starter",
"copy-keycloak-resources-to-storybook-static": "PUBLIC_DIR_PATH=.storybook/static node dist/bin/copy-keycloak-resources-to-public.js",
"storybook": "yarn build && yarn copy-keycloak-resources-to-storybook-static && start-storybook -p 6006",
"build-storybook": "yarn build && yarn copy-keycloak-resources-to-storybook-static && build-storybook"
"tsc-watch": "tsc -p src/bin -w & tsc -p src/lib -w",
"storybook": "start-storybook -p 6006",
"build-storybook": "build-storybook",
"copy-fonts": "copyfiles -u 2 .storybook/static/fonts/**/* stories/assets"
},
"bin": {
"copy-keycloak-resources-to-public": "dist/bin/copy-keycloak-resources-to-public.js",
"download-builtin-keycloak-theme": "dist/bin/download-builtin-keycloak-theme.js",
"eject-keycloak-page": "dist/bin/eject-keycloak-page.js",
"keycloakify": "dist/bin/keycloakify/index.js",
"initialize-email-theme": "dist/bin/initialize-email-theme.js",
"keycloakify": "dist/bin/keycloakify/index.js"
"download-builtin-keycloak-theme": "dist/bin/download-builtin-keycloak-theme.js",
"eject-keycloak-page": "dist/bin/eject-keycloak-page.js"
},
"lint-staged": {
"*.{ts,tsx,json,md}": [
@ -69,7 +69,6 @@
},
"devDependencies": {
"@babel/core": "^7.0.0",
"@emotion/react": "^11.10.6",
"@storybook/addon-a11y": "^6.5.16",
"@storybook/addon-actions": "^6.5.13",
"@storybook/addon-essentials": "^6.5.13",
@ -85,12 +84,11 @@
"@types/react": "^18.0.35",
"@types/react-dom": "^18.0.11",
"@types/yauzl": "^2.10.0",
"concurrently": "^8.0.1",
"concurrently": "^7.6.0",
"copyfiles": "^2.4.1",
"eslint-plugin-storybook": "^0.6.7",
"husky": "^4.3.8",
"lint-staged": "^11.0.0",
"powerhooks": "^0.26.7",
"prettier": "^2.3.0",
"properties-parser": "^0.3.1",
"react": "^18.2.0",
@ -100,8 +98,7 @@
"storybook-dark-mode": "^1.1.2",
"ts-node": "^10.9.1",
"tsc-alias": "^1.8.3",
"tss-react": "^4.8.2",
"typescript": "^4.9.1-beta",
"typescript": "^5.0.4",
"vitest": "^0.29.8",
"zod-to-json-schema": "^3.20.4"
},

View File

@ -4,6 +4,7 @@ import { join as pathJoin, relative as pathRelative, dirname as pathDirname, sep
import { crawl } from "../src/bin/tools/crawl";
import { downloadBuiltinKeycloakTheme } from "../src/bin/download-builtin-keycloak-theme";
import { getProjectRoot } from "../src/bin/tools/getProjectRoot";
import { getCliOptions } from "../src/bin/tools/cliOptions";
import { getLogger } from "../src/bin/tools/logger";
// NOTE: To run without argument when we want to generate src/i18n/generated_kcMessages files,
@ -12,8 +13,7 @@ import { getLogger } from "../src/bin/tools/logger";
//@ts-ignore
const propertiesParser = require("properties-parser");
const isSilent = true;
const { isSilent } = getCliOptions(process.argv.slice(2));
const logger = getLogger({ isSilent });
async function main() {

View File

@ -60,7 +60,7 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
</div>
</li>
)}
{referrer?.url && (
{referrer?.url !== undefined && (
<li>
<a href={referrer.url} id="referrer">
{msg("backTo", referrer.name)}

View File

@ -211,9 +211,7 @@ const keycloakifyExtraMessages = {
"shouldBeDifferent": "{0} should be different to {1}",
"shouldMatchPattern": "Pattern should match: `/{0}/`",
"mustBeAnInteger": "Must be an integer",
"notAValidOption": "Not a valid option",
"newPasswordSameAsOld": "New password must be different from the old one",
"passwordConfirmNotMatch": "Password confirmation does not match"
"notAValidOption": "Not a valid option"
},
"fr": {
/* spell-checker: disable */
@ -225,9 +223,7 @@ const keycloakifyExtraMessages = {
"logoutConfirmTitle": "Déconnexion",
"logoutConfirmHeader": "Êtes-vous sûr(e) de vouloir vous déconnecter ?",
"doLogout": "Se déconnecter",
"newPasswordSameAsOld": "Le nouveau mot de passe doit être différent de l'ancien",
"passwordConfirmNotMatch": "La confirmation du mot de passe ne correspond pas"
"doLogout": "Se déconnecter"
/* spell-checker: enable */
}
};

View File

@ -1,4 +1,4 @@
import type { AccountThemePageId, ThemeType } from "keycloakify/bin/keycloakify/generateFtl";
import type { AccountThemePageId } from "keycloakify/bin/keycloakify/generateFtl";
import { assert } from "tsafe/assert";
import type { Equals } from "tsafe";
@ -7,7 +7,6 @@ export type KcContext = KcContext.Password | KcContext.Account;
export declare namespace KcContext {
export type Common = {
keycloakifyVersion: string;
themeType: "account";
locale?: {
supported: {
url: string;
@ -27,8 +26,6 @@ export declare namespace KcContext {
resourceUrl: string;
resourcesCommonPath: string;
resourcesPath: string;
/** @deprecated, not present in recent keycloak version apparently, use kcContext.referrer instead */
referrerURI?: string;
getLogoutUrl: () => string;
};
features: {
@ -41,14 +38,13 @@ export declare namespace KcContext {
internationalizationEnabled: boolean;
userManagedAccessAllowed: boolean;
};
// Present only if redirected to account page with ?referrer=xxx&referrer_uri=http...
message?: {
type: "success" | "warning" | "error" | "info";
summary: string;
};
referrer?: {
url: string; // The url of the App
name: string; // Client id
url?: string;
name: string;
};
messagesPerField: {
printIfExists: <T>(fieldName: string, x: T) => T | undefined;
@ -75,6 +71,7 @@ export declare namespace KcContext {
export type Account = Common & {
pageId: "account.ftl";
url: {
referrerURI: string;
accountUrl: string;
};
realm: {
@ -85,15 +82,4 @@ export declare namespace KcContext {
};
}
{
type Got = KcContext["pageId"];
type Expected = AccountThemePageId;
type OnlyInGot = Exclude<Got, Expected>;
type OnlyInExpected = Exclude<Expected, Got>;
assert<Equals<OnlyInGot, never>>();
assert<Equals<OnlyInExpected, never>>();
}
assert<KcContext["themeType"] extends ThemeType ? true : false>();
assert<Equals<KcContext["pageId"], AccountThemePageId>>();

View File

@ -4,9 +4,11 @@ import type { ExtendKcContext } from "./getKcContextFromWindow";
import { getKcContextFromWindow } from "./getKcContextFromWindow";
import { pathJoin } from "keycloakify/bin/tools/pathJoin";
import { pathBasename } from "keycloakify/tools/pathBasename";
import { resourcesCommonDirPathRelativeToPublicDir } from "keycloakify/bin/mockTestingResourcesPath";
import { mockTestingResourcesCommonPath } from "keycloakify/bin/mockTestingResourcesPath";
import { symToStr } from "tsafe/symToStr";
import { kcContextMocks, kcContextCommonMock } from "keycloakify/account/kcContext/kcContextMocks";
import { id } from "tsafe/id";
import { accountThemePageIds } from "keycloakify/bin/keycloakify/generateFtl/pageId";
export function createGetKcContext<KcContextExtension extends { pageId: string } = never>(params?: {
mockData?: readonly DeepPartial<ExtendKcContext<KcContextExtension>>[];
@ -28,7 +30,13 @@ export function createGetKcContext<KcContextExtension extends { pageId: string }
if (mockPageId !== undefined && realKcContext === undefined) {
//TODO maybe trow if no mock fo custom page
console.log(`%cKeycloakify: ${symToStr({ mockPageId })} set to ${mockPageId}.`, "background: red; color: yellow; font-size: medium");
console.log(
[
`%cKeycloakify: ${symToStr({ mockPageId })} set to ${mockPageId}.`,
`If assets are missing make sure you have built your Keycloak theme at least once.`
].join(" "),
"background: red; color: yellow; font-size: medium"
);
const kcContextDefaultMock = kcContextMocks.find(({ pageId }) => pageId === mockPageId);
@ -85,14 +93,14 @@ export function createGetKcContext<KcContextExtension extends { pageId: string }
return { "kcContext": undefined as any };
}
if (realKcContext.themeType !== "account") {
if (id<readonly string[]>(accountThemePageIds).indexOf(realKcContext.pageId) < 0 && !("account" in realKcContext)) {
return { "kcContext": undefined as any };
}
{
const { url } = realKcContext;
url.resourcesCommonPath = pathJoin(url.resourcesPath, pathBasename(resourcesCommonDirPathRelativeToPublicDir));
url.resourcesCommonPath = pathJoin(url.resourcesPath, pathBasename(mockTestingResourcesCommonPath));
}
return { "kcContext": realKcContext as any };

View File

@ -1,5 +1,5 @@
import "minimal-polyfills/Object.fromEntries";
import { resourcesCommonDirPathRelativeToPublicDir, resourcesDirPathRelativeToPublicDir } from "keycloakify/bin/mockTestingResourcesPath";
import { mockTestingResourcesCommonPath, mockTestingResourcesPath } from "keycloakify/bin/mockTestingResourcesPath";
import { pathJoin } from "keycloakify/bin/tools/pathJoin";
import { id } from "tsafe/id";
import type { KcContext } from "./KcContext";
@ -8,10 +8,9 @@ const PUBLIC_URL = process.env["PUBLIC_URL"] ?? "/";
export const kcContextCommonMock: KcContext.Common = {
"keycloakifyVersion": "0.0.0",
"themeType": "account",
"url": {
"resourcesPath": pathJoin(PUBLIC_URL, resourcesDirPathRelativeToPublicDir),
"resourcesCommonPath": pathJoin(PUBLIC_URL, resourcesCommonDirPathRelativeToPublicDir),
"resourcesPath": pathJoin(PUBLIC_URL, mockTestingResourcesPath),
"resourcesCommonPath": pathJoin(PUBLIC_URL, mockTestingResourcesCommonPath),
"resourceUrl": "#",
"accountUrl": "#",
"applicationsUrl": "#",

View File

@ -4,7 +4,7 @@ import { useGetClassName } from "keycloakify/account/lib/useGetClassName";
import type { KcContext } from "../kcContext";
import type { I18n } from "../i18n";
export default function Account(props: PageProps<Extract<KcContext, { pageId: "account.ftl" }>, I18n>) {
export default function LogoutConfirm(props: PageProps<Extract<KcContext, { pageId: "account.ftl" }>, I18n>) {
const { kcContext, i18n, doUseDefaultCss, Template, classes } = props;
const { getClassName } = useGetClassName({
@ -15,7 +15,7 @@ export default function Account(props: PageProps<Extract<KcContext, { pageId: "a
}
});
const { url, realm, messagesPerField, stateChecker, account, referrer } = kcContext;
const { url, realm, messagesPerField, stateChecker, account } = kcContext;
const { msg } = i18n;
@ -99,7 +99,7 @@ export default function Account(props: PageProps<Extract<KcContext, { pageId: "a
<div className="form-group">
<div id="kc-form-buttons" className="col-md-offset-2 col-md-10 submit">
<div>
{referrer !== undefined && <a href={referrer?.url}>{msg("backToApplication")}</a>}
{url.referrerURI !== undefined && <a href={url.referrerURI}>${msg("backToApplication")}</a>}
<button
type="submit"
className={clsx(

View File

@ -1,11 +1,10 @@
import { useState } from "react";
import { clsx } from "keycloakify/tools/clsx";
import type { PageProps } from "keycloakify/account/pages/PageProps";
import { useGetClassName } from "keycloakify/account/lib/useGetClassName";
import type { KcContext } from "../kcContext";
import type { I18n } from "../i18n";
export default function Password(props: PageProps<Extract<KcContext, { pageId: "password.ftl" }>, I18n>) {
export default function LogoutConfirm(props: PageProps<Extract<KcContext, { pageId: "password.ftl" }>, I18n>) {
const { kcContext, i18n, doUseDefaultCss, Template, classes } = props;
const { getClassName } = useGetClassName({
@ -18,69 +17,10 @@ export default function Password(props: PageProps<Extract<KcContext, { pageId: "
const { url, password, account, stateChecker } = kcContext;
const { msgStr, msg } = i18n;
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [newPasswordConfirm, setNewPasswordConfirm] = useState("");
const [newPasswordError, setNewPasswordError] = useState("");
const [newPasswordConfirmError, setNewPasswordConfirmError] = useState("");
const [hasNewPasswordBlurred, setHasNewPasswordBlurred] = useState(false);
const [hasNewPasswordConfirmBlurred, setHasNewPasswordConfirmBlurred] = useState(false);
const checkNewPassword = (newPassword: string) => {
if (!password.passwordSet) {
return;
}
if (newPassword === currentPassword) {
setNewPasswordError(msgStr("newPasswordSameAsOld"));
} else {
setNewPasswordError("");
}
};
const checkNewPasswordConfirm = (newPasswordConfirm: string) => {
if (newPasswordConfirm === "") {
return;
}
if (newPassword !== newPasswordConfirm) {
setNewPasswordConfirmError(msgStr("passwordConfirmNotMatch"));
} else {
setNewPasswordConfirmError("");
}
};
const { msg } = i18n;
return (
<Template
{...{
kcContext: {
...kcContext,
"message": (() => {
if (newPasswordError !== "") {
return {
"type": "error",
"summary": newPasswordError
};
}
if (newPasswordConfirmError !== "") {
return {
"type": "error",
"summary": newPasswordConfirmError
};
}
return kcContext.message;
})()
},
i18n,
doUseDefaultCss,
classes
}}
active="password"
>
<Template {...{ kcContext, i18n, doUseDefaultCss, classes }} active="password">
<div className="row">
<div className="col-md-10">
<h2>{msg("changePasswordHtmlTitle")}</h2>
@ -108,17 +48,9 @@ export default function Password(props: PageProps<Extract<KcContext, { pageId: "
{msg("password")}
</label>
</div>
<div className="col-sm-10 col-md-10">
<input
type="password"
className="form-control"
id="password"
name="password"
autoFocus
autoComplete="current-password"
value={currentPassword}
onChange={event => setCurrentPassword(event.target.value)}
/>
<input type="password" className="form-control" id="password" name="password" autoFocus autoComplete="current-password" />
</div>
</div>
)}
@ -131,27 +63,9 @@ export default function Password(props: PageProps<Extract<KcContext, { pageId: "
{msg("passwordNew")}
</label>
</div>
<div className="col-sm-10 col-md-10">
<input
type="password"
className="form-control"
id="password-new"
name="password-new"
autoComplete="new-password"
value={newPassword}
onChange={event => {
const newPassword = event.target.value;
setNewPassword(newPassword);
if (hasNewPasswordBlurred) {
checkNewPassword(newPassword);
}
}}
onBlur={() => {
setHasNewPasswordBlurred(true);
checkNewPassword(newPassword);
}}
/>
<div className="col-sm-10 col-md-10">
<input type="password" className="form-control" id="password-new" name="password-new" autoComplete="new-password" />
</div>
</div>
@ -163,26 +77,7 @@ export default function Password(props: PageProps<Extract<KcContext, { pageId: "
</div>
<div className="col-sm-10 col-md-10">
<input
type="password"
className="form-control"
id="password-confirm"
name="password-confirm"
autoComplete="new-password"
value={newPasswordConfirm}
onChange={event => {
const newPasswordConfirm = event.target.value;
setNewPasswordConfirm(newPasswordConfirm);
if (hasNewPasswordConfirmBlurred) {
checkNewPasswordConfirm(newPasswordConfirm);
}
}}
onBlur={() => {
setHasNewPasswordConfirmBlurred(true);
checkNewPasswordConfirm(newPasswordConfirm);
}}
/>
<input type="password" className="form-control" id="password-confirm" name="password-confirm" autoComplete="new-password" />
</div>
</div>
@ -190,7 +85,6 @@ export default function Password(props: PageProps<Extract<KcContext, { pageId: "
<div id="kc-form-buttons" className="col-md-offset-2 col-md-10 submit">
<div>
<button
disabled={newPasswordError !== "" || newPasswordConfirmError !== ""}
type="submit"
className={clsx(
getClassName("kcButtonClass"),

View File

@ -1,48 +0,0 @@
#!/usr/bin/env node
import { downloadKeycloakStaticResources } from "./keycloakify/generateTheme/downloadKeycloakStaticResources";
import { join as pathJoin, relative as pathRelative } from "path";
import { basenameOfKeycloakDirInPublicDir } from "./mockTestingResourcesPath";
import { readBuildOptions } from "./keycloakify/BuildOptions";
import { themeTypes } from "./keycloakify/generateFtl";
import * as fs from "fs";
(async () => {
const projectDirPath = process.cwd();
const buildOptions = readBuildOptions({
"processArgv": process.argv.slice(2),
"projectDirPath": process.cwd()
});
const keycloakDirInPublicDir = pathJoin(process.env["PUBLIC_DIR_PATH"] || pathJoin(projectDirPath, "public"), basenameOfKeycloakDirInPublicDir);
if (fs.existsSync(keycloakDirInPublicDir)) {
console.log(`${pathRelative(projectDirPath, keycloakDirInPublicDir)} already exists.`);
return;
}
for (const themeType of themeTypes) {
await downloadKeycloakStaticResources({
"isSilent": false,
"keycloakVersion": buildOptions.keycloakVersionDefaultAssets,
"themeType": themeType,
"themeDirPath": keycloakDirInPublicDir
});
}
fs.writeFileSync(
pathJoin(keycloakDirInPublicDir, "README.txt"),
Buffer.from(
// prettier-ignore
[
"This is just a test folder that helps develop",
"the login and register page without having to run a Keycloak container"
].join(" ")
)
);
fs.writeFileSync(pathJoin(keycloakDirInPublicDir, ".gitignore"), Buffer.from("*", "utf8"));
console.log(`${pathRelative(projectDirPath, keycloakDirInPublicDir)} directory created.`);
})();

View File

@ -2,6 +2,7 @@
import { join as pathJoin } from "path";
import { downloadAndUnzip } from "./tools/downloadAndUnzip";
import { promptKeycloakVersion } from "./promptKeycloakVersion";
import { getCliOptions } from "./tools/cliOptions";
import { getLogger } from "./tools/logger";
import { readBuildOptions } from "./keycloakify/BuildOptions";
@ -20,22 +21,28 @@ export async function downloadBuiltinKeycloakTheme(params: { keycloakVersion: st
}
async function main() {
const buildOptions = readBuildOptions({
"projectDirPath": process.cwd(),
"processArgv": process.argv.slice(2)
});
const logger = getLogger({ "isSilent": buildOptions.isSilent });
const { isSilent } = getCliOptions(process.argv.slice(2));
const logger = getLogger({ isSilent });
const { keycloakVersion } = await promptKeycloakVersion();
const destDirPath = pathJoin(buildOptions.keycloakifyBuildDirPath, "src", "main", "resources", "theme");
const destDirPath = pathJoin(
readBuildOptions({
"isSilent": true,
"isExternalAssetsCliParamProvided": false,
"projectDirPath": process.cwd()
}).keycloakifyBuildDirPath,
"src",
"main",
"resources",
"theme"
);
logger.log(`Downloading builtins theme of Keycloak ${keycloakVersion} here ${destDirPath}`);
await downloadBuiltinKeycloakTheme({
keycloakVersion,
destDirPath,
"isSilent": buildOptions.isSilent
isSilent
});
}

View File

@ -4,17 +4,13 @@ import { downloadBuiltinKeycloakTheme } from "./download-builtin-keycloak-theme"
import { join as pathJoin, relative as pathRelative } from "path";
import { transformCodebase } from "./tools/transformCodebase";
import { promptKeycloakVersion } from "./promptKeycloakVersion";
import { readBuildOptions } from "./keycloakify/BuildOptions";
import * as fs from "fs";
import { getCliOptions } from "./tools/cliOptions";
import { getLogger } from "./tools/logger";
import { getEmailThemeSrcDirPath } from "./getSrcDirPath";
export async function main() {
const { isSilent } = readBuildOptions({
"projectDirPath": process.cwd(),
"processArgv": process.argv.slice(2)
});
const { isSilent } = getCliOptions(process.argv.slice(2));
const logger = getLogger({ isSilent });
const { emailThemeSrcDirPath } = getEmailThemeSrcDirPath({

View File

@ -6,7 +6,6 @@ import { symToStr } from "tsafe/symToStr";
import { bundlers, getParsedPackageJson, type Bundler } from "./parsedPackageJson";
import * as fs from "fs";
import { join as pathJoin, sep as pathSep } from "path";
import parseArgv from "minimist";
/** Consolidated build option gathered form CLI arguments and config in package.json */
export type BuildOptions = BuildOptions.Standalone | BuildOptions.ExternalAssets;
@ -54,17 +53,8 @@ export namespace BuildOptions {
}
}
export function readBuildOptions(params: { projectDirPath: string; processArgv: string[] }): BuildOptions {
const { projectDirPath, processArgv } = params;
const { isExternalAssetsCliParamProvided, isSilentCliParamProvided } = (() => {
const argv = parseArgv(processArgv);
return {
"isSilentCliParamProvided": typeof argv["silent"] === "boolean" ? argv["silent"] : false,
"isExternalAssetsCliParamProvided": typeof argv["external-assets"] === "boolean" ? argv["external-assets"] : false
};
})();
export function readBuildOptions(params: { projectDirPath: string; isExternalAssetsCliParamProvided: boolean; isSilent: boolean }): BuildOptions {
const { projectDirPath, isExternalAssetsCliParamProvided, isSilent } = params;
const parsedPackageJson = getParsedPackageJson({ projectDirPath });
@ -153,7 +143,7 @@ export function readBuildOptions(params: { projectDirPath: string; processArgv:
"extraLoginPages": [...(extraPages ?? []), ...(extraLoginPages ?? [])],
extraAccountPages,
extraThemeProperties,
"isSilent": isSilentCliParamProvided,
isSilent,
"keycloakVersionDefaultAssets": keycloakVersionDefaultAssets ?? "11.0.3",
"reactAppBuildDirPath": (() => {
let { reactAppBuildDirPath = undefined } = parsedPackageJson.keycloakify ?? {};

View File

@ -121,7 +121,6 @@
out["keycloakifyVersion"] = "KEYCLOAKIFY_VERSION_xEdKd3xEdr";
out["themeVersion"] = "KEYCLOAKIFY_THEME_VERSION_sIgKd3xEdr3dx";
out["themeType"] = "KEYCLOAKIFY_THEME_TYPE_dExKd3xEdr";
out["pageId"] = "${pageId}";
return out;
@ -337,17 +336,6 @@
</#if>
<#local isDate = "">
<#attempt>
<#local isDate = object?is_date_like>
<#recover>
<#return "ABORT: Can't test if it's a date">
</#attempt>
<#if isDate>
<#return '"' + object?datetime?iso_utc + '"'>
</#if>
<#attempt>
<#return '"' + object?js_string + '"'>;
<#recover>

View File

@ -55,9 +55,8 @@ export function generateFtlFilesCodeFactory(params: {
cssGlobalsToDefine: Record<string, string>;
buildOptions: BuildOptionsLike;
keycloakifyVersion: string;
themeType: ThemeType;
}) {
const { cssGlobalsToDefine, indexHtmlCode, buildOptions, keycloakifyVersion, themeType } = params;
const { cssGlobalsToDefine, indexHtmlCode, buildOptions, keycloakifyVersion } = params;
const $ = cheerio.load(indexHtmlCode);
@ -133,8 +132,7 @@ export function generateFtlFilesCodeFactory(params: {
buildOptions.customUserAttributes.length === 0 ? "" : ", " + buildOptions.customUserAttributes.map(name => `"${name}"`).join(", ")
)
.replace("KEYCLOAKIFY_VERSION_xEdKd3xEdr", keycloakifyVersion)
.replace("KEYCLOAKIFY_THEME_VERSION_sIgKd3xEdr3dx", buildOptions.themeVersion)
.replace("KEYCLOAKIFY_THEME_TYPE_dExKd3xEdr", themeType),
.replace("KEYCLOAKIFY_THEME_VERSION_sIgKd3xEdr3dx", buildOptions.themeVersion),
"<!-- xIdLqMeOedErIdLsPdNdI9dSlxI -->": [
"<#if scripts??>",
" <#list scripts as script>",

View File

@ -21,8 +21,7 @@ export const loginThemePageIds = [
"update-user-profile.ftl",
"idp-review-user-profile.ftl",
"update-email.ftl",
"select-authenticator.ftl",
"saml-post-form.ftl"
"select-authenticator.ftl"
] as const;
export const accountThemePageIds = ["password.ftl", "account.ftl"] as const;

View File

@ -1,14 +1,14 @@
import { transformCodebase } from "../../tools/transformCodebase";
import { transformCodebase } from "../tools/transformCodebase";
import * as fs from "fs";
import { join as pathJoin } from "path";
import { replaceImportsFromStaticInJsCode } from "../replacers/replaceImportsFromStaticInJsCode";
import { replaceImportsInCssCode } from "../replacers/replaceImportsInCssCode";
import { generateFtlFilesCodeFactory, loginThemePageIds, accountThemePageIds, themeTypes, type ThemeType } from "../generateFtl";
import { basenameOfKeycloakDirInPublicDir } from "../../mockTestingResourcesPath";
import { isInside } from "../../tools/isInside";
import type { BuildOptions } from "../BuildOptions";
import { join as pathJoin, basename as pathBasename } from "path";
import { replaceImportsFromStaticInJsCode } from "./replacers/replaceImportsFromStaticInJsCode";
import { replaceImportsInCssCode } from "./replacers/replaceImportsInCssCode";
import { generateFtlFilesCodeFactory, loginThemePageIds, accountThemePageIds, themeTypes, type ThemeType } from "./generateFtl";
import { downloadBuiltinKeycloakTheme } from "../download-builtin-keycloak-theme";
import { mockTestingResourcesCommonPath, mockTestingResourcesPath, mockTestingSubDirOfPublicDirBasename } from "../mockTestingResourcesPath";
import { isInside } from "../tools/isInside";
import type { BuildOptions } from "./BuildOptions";
import { assert } from "tsafe/assert";
import { downloadKeycloakStaticResources } from "./downloadKeycloakStaticResources";
export type BuildOptionsLike = BuildOptionsLike.Standalone | BuildOptionsLike.ExternalAssets;
@ -21,7 +21,6 @@ export namespace BuildOptionsLike {
isSilent: boolean;
customUserAttributes: string[];
themeVersion: string;
keycloakVersionDefaultAssets: string;
};
export type Standalone = Common & {
@ -50,14 +49,15 @@ export namespace BuildOptionsLike {
assert<BuildOptions extends BuildOptionsLike ? true : false>();
export async function generateTheme(params: {
export async function generateKeycloakThemeResources(params: {
reactAppBuildDirPath: string;
keycloakThemeBuildingDirPath: string;
emailThemeSrcDirPath: string | undefined;
keycloakVersion: string;
buildOptions: BuildOptionsLike;
keycloakifyVersion: string;
}): Promise<{ doBundlesEmailTemplate: boolean }> {
const { reactAppBuildDirPath, keycloakThemeBuildingDirPath, emailThemeSrcDirPath, buildOptions, keycloakifyVersion } = params;
const { reactAppBuildDirPath, keycloakThemeBuildingDirPath, emailThemeSrcDirPath, keycloakVersion, buildOptions, keycloakifyVersion } = params;
const getThemeDirPath = (themeType: ThemeType | "email") =>
pathJoin(keycloakThemeBuildingDirPath, "src", "main", "resources", "theme", buildOptions.themeName, themeType);
@ -84,7 +84,7 @@ export async function generateTheme(params: {
if (
buildOptions.isStandalone &&
isInside({
"dirPath": pathJoin(reactAppBuildDirPath, basenameOfKeycloakDirInPublicDir),
"dirPath": pathJoin(reactAppBuildDirPath, mockTestingSubDirOfPublicDirBasename),
filePath
})
) {
@ -141,8 +141,7 @@ export async function generateTheme(params: {
"indexHtmlCode": fs.readFileSync(pathJoin(reactAppBuildDirPath, "index.html")).toString("utf8"),
"cssGlobalsToDefine": allCssGlobalsToDefine,
buildOptions,
keycloakifyVersion,
themeType
keycloakifyVersion
});
return generateFtlFilesCode;
@ -173,47 +172,49 @@ export async function generateTheme(params: {
fs.writeFileSync(pathJoin(themeDirPath, pageId), Buffer.from(ftlCode, "utf8"));
});
//TODO: Remove this block we left it for now only for backward compatibility
// we now have a separate script for this
copy_keycloak_resources_to_public: {
const keycloakDirInPublicDir = pathJoin(reactAppBuildDirPath, "..", "public", basenameOfKeycloakDirInPublicDir);
{
const tmpDirPath = pathJoin(themeDirPath, "..", "tmp_xxKdLpdIdLd");
if (fs.existsSync(keycloakDirInPublicDir)) {
break copy_keycloak_resources_to_public;
}
await downloadKeycloakStaticResources({
"isSilent": buildOptions.isSilent,
"keycloakVersion": buildOptions.keycloakVersionDefaultAssets,
"themeDirPath": keycloakDirInPublicDir,
themeType
await downloadBuiltinKeycloakTheme({
keycloakVersion,
"destDirPath": tmpDirPath,
isSilent: buildOptions.isSilent
});
if (themeType !== themeTypes[0]) {
break copy_keycloak_resources_to_public;
}
const themeResourcesDirPath = pathJoin(themeDirPath, "resources");
transformCodebase({
"srcDirPath": pathJoin(tmpDirPath, "keycloak", themeType, "resources"),
"destDirPath": themeResourcesDirPath
});
const reactAppPublicDirPath = pathJoin(reactAppBuildDirPath, "..", "public");
transformCodebase({
"srcDirPath": pathJoin(tmpDirPath, "keycloak", "common", "resources"),
"destDirPath": pathJoin(themeResourcesDirPath, pathBasename(mockTestingResourcesCommonPath))
});
transformCodebase({
"srcDirPath": themeResourcesDirPath,
"destDirPath": pathJoin(reactAppPublicDirPath, mockTestingResourcesPath)
});
const keycloakResourcesWithinPublicDirPath = pathJoin(reactAppPublicDirPath, mockTestingSubDirOfPublicDirBasename);
fs.writeFileSync(
pathJoin(keycloakDirInPublicDir, "README.txt"),
pathJoin(keycloakResourcesWithinPublicDirPath, "README.txt"),
Buffer.from(
// prettier-ignore
[
"This is just a test folder that helps develop",
"the login and register page without having to run a Keycloak container"
].join(" ")
["This is just a test folder that helps develop", "the login and register page without having to run a Keycloak container"].join(
" "
)
)
);
fs.writeFileSync(pathJoin(keycloakDirInPublicDir, ".gitignore"), Buffer.from("*", "utf8"));
fs.writeFileSync(pathJoin(keycloakResourcesWithinPublicDirPath, ".gitignore"), Buffer.from("*", "utf8"));
fs.rmSync(tmpDirPath, { recursive: true, force: true });
}
await downloadKeycloakStaticResources({
"isSilent": buildOptions.isSilent,
"keycloakVersion": buildOptions.keycloakVersionDefaultAssets,
themeDirPath,
themeType
});
fs.writeFileSync(
pathJoin(themeDirPath, "theme.properties"),
Buffer.from(["parent=keycloak", ...(buildOptions.extraThemeProperties ?? [])].join("\n\n"), "utf8")

View File

@ -1,47 +0,0 @@
import { transformCodebase } from "../../tools/transformCodebase";
import * as fs from "fs";
import { join as pathJoin, relative as pathRelative } from "path";
import type { ThemeType } from "../generateFtl";
import { downloadBuiltinKeycloakTheme } from "../../download-builtin-keycloak-theme";
import {
resourcesCommonDirPathRelativeToPublicDir,
resourcesDirPathRelativeToPublicDir,
basenameOfKeycloakDirInPublicDir
} from "../../mockTestingResourcesPath";
import * as crypto from "crypto";
export async function downloadKeycloakStaticResources(
// prettier-ignore
params: {
themeType: ThemeType;
themeDirPath: string;
isSilent: boolean;
keycloakVersion: string;
}
) {
const { themeType, isSilent, themeDirPath, keycloakVersion } = params;
const tmpDirPath = pathJoin(
themeDirPath,
"..",
`tmp_suLeKsxId_${crypto.createHash("sha256").update(`${themeType}-${keycloakVersion}`).digest("hex").slice(0, 8)}`
);
await downloadBuiltinKeycloakTheme({
keycloakVersion,
"destDirPath": tmpDirPath,
isSilent
});
transformCodebase({
"srcDirPath": pathJoin(tmpDirPath, "keycloak", themeType, "resources"),
"destDirPath": pathJoin(themeDirPath, pathRelative(basenameOfKeycloakDirInPublicDir, resourcesDirPathRelativeToPublicDir))
});
transformCodebase({
"srcDirPath": pathJoin(tmpDirPath, "keycloak", "common", "resources"),
"destDirPath": pathJoin(themeDirPath, pathRelative(basenameOfKeycloakDirInPublicDir, resourcesCommonDirPathRelativeToPublicDir))
});
fs.rmSync(tmpDirPath, { "recursive": true, "force": true });
}

View File

@ -1 +0,0 @@
export * from "./generateTheme";

View File

@ -1,4 +1,4 @@
import { generateTheme } from "./generateTheme";
import { generateKeycloakThemeResources } from "./generateKeycloakThemeResources";
import { generateJavaStackFiles } from "./generateJavaStackFiles";
import { join as pathJoin, relative as pathRelative, basename as pathBasename, sep as pathSep } from "path";
import * as child_process from "child_process";
@ -6,6 +6,7 @@ import { generateStartKeycloakTestingContainer } from "./generateStartKeycloakTe
import * as fs from "fs";
import { readBuildOptions } from "./BuildOptions";
import { getLogger } from "../tools/logger";
import { getCliOptions } from "../tools/cliOptions";
import jar from "../tools/jar";
import { assert } from "tsafe/assert";
import { Equals } from "tsafe";
@ -13,17 +14,19 @@ import { getEmailThemeSrcDirPath } from "../getSrcDirPath";
import { getProjectRoot } from "../tools/getProjectRoot";
export async function main() {
const { isSilent, hasExternalAssets } = getCliOptions(process.argv.slice(2));
const logger = getLogger({ isSilent });
logger.log("🔏 Building the keycloak theme...⌚");
const projectDirPath = process.cwd();
const buildOptions = readBuildOptions({
projectDirPath,
"processArgv": process.argv.slice(2)
"isExternalAssetsCliParamProvided": hasExternalAssets,
"isSilent": isSilent
});
const logger = getLogger({ "isSilent": buildOptions.isSilent });
logger.log("🔏 Building the keycloak theme...⌚");
const { doBundlesEmailTemplate } = await generateTheme({
const { doBundlesEmailTemplate } = await generateKeycloakThemeResources({
keycloakThemeBuildingDirPath: buildOptions.keycloakifyBuildDirPath,
"emailThemeSrcDirPath": (() => {
const { emailThemeSrcDirPath } = getEmailThemeSrcDirPath({ projectDirPath });
@ -36,6 +39,7 @@ export async function main() {
})(),
"reactAppBuildDirPath": buildOptions.reactAppBuildDirPath,
buildOptions,
"keycloakVersion": buildOptions.keycloakVersionDefaultAssets,
"keycloakifyVersion": (() => {
const version = JSON.parse(fs.readFileSync(pathJoin(getProjectRoot(), "package.json")).toString("utf8"))["version"];
@ -58,7 +62,7 @@ export async function main() {
case "keycloakify":
logger.log("🫶 Let keycloakify do its thang");
await jar({
"rootPath": buildOptions.keycloakifyBuildDirPath,
"rootPath": pathJoin(buildOptions.keycloakifyBuildDirPath, "src", "main", "resources"),
"version": buildOptions.themeVersion,
"groupId": buildOptions.groupId,
"artifactId": buildOptions.artifactId,
@ -128,18 +132,16 @@ export async function main() {
``,
`Once your container is up and running: `,
"- Log into the admin console 👉 http://localhost:8080/admin username: admin, password: admin 👈",
`- Create a realm: Master -> AddRealm -> Name: myrealm`,
`- Enable registration: Realm settings -> Login tab -> User registration: on`,
`- Enable the Account theme (optional): Realm settings -> Themes tab -> Account theme: ${buildOptions.themeName}`,
` Clients -> account -> Login theme: ${buildOptions.themeName}`,
`- Enable the email theme (optional): Realm settings -> Themes tab -> Email theme: ${buildOptions.themeName} (option will appear only if you have ran npx initialize-email-theme)`,
`- Create a client Clients -> Create -> Client ID: myclient`,
` Root URL: https://www.keycloak.org/app/`,
` Valid redirect URIs: https://www.keycloak.org/app* http://localhost* (localhost is optional)`,
` Valid post logout redirect URIs: https://www.keycloak.org/app* http://localhost*`,
` Web origins: *`,
` Login Theme: ${buildOptions.themeName}`,
` Save (button at the bottom of the page)`,
`- Create a realm: myrealm`,
`- Enable registration: Realm settings -> Login tab -> User registration: on`,
`- Enable the Account theme: Realm settings -> Themes tab -> Account theme, select ${buildOptions.themeName} `,
`- Create a client id myclient`,
` Root URL: https://www.keycloak.org/app/`,
` Valid redirect URIs: https://www.keycloak.org/app* http://localhost* (localhost is optional)`,
` Valid post logout redirect URIs: https://www.keycloak.org/app* http://localhost*`,
` Web origins: *`,
` Login Theme: ${buildOptions.themeName}`,
` Save (button at the bottom of the page)`,
``,
`- Go to 👉 https://www.keycloak.org/app/ 👈 Click "Save" then "Sign in". You should see your login page`,
`- Got to 👉 http://localhost:8080/realms/myrealm/account 👈 to see your account theme`,

View File

@ -1,5 +1,5 @@
import { pathJoin } from "./tools/pathJoin";
export const basenameOfKeycloakDirInPublicDir = "keycloak-resources";
export const resourcesDirPathRelativeToPublicDir = pathJoin(basenameOfKeycloakDirInPublicDir, "resources");
export const resourcesCommonDirPathRelativeToPublicDir = pathJoin(resourcesDirPathRelativeToPublicDir, "resources_common");
export const mockTestingSubDirOfPublicDirBasename = "keycloak_static";
export const mockTestingResourcesPath = pathJoin(mockTestingSubDirOfPublicDirBasename, "resources");
export const mockTestingResourcesCommonPath = pathJoin(mockTestingResourcesPath, "resources_common");

View File

@ -0,0 +1,15 @@
import parseArgv from "minimist";
export type CliOptions = {
isSilent: boolean;
hasExternalAssets: boolean;
};
export const getCliOptions = (processArgv: string[]): CliOptions => {
const argv = parseArgv(processArgv);
return {
isSilent: typeof argv["silent"] === "boolean" ? argv["silent"] : false,
hasExternalAssets: typeof argv["external-assets"] === "boolean" ? argv["external-assets"] : false
};
};

View File

@ -1,6 +1,6 @@
import { exec as execCallback } from "child_process";
import { createHash } from "crypto";
import { mkdir, readFile, stat, writeFile } from "fs/promises";
import { mkdir, stat, writeFile } from "fs/promises";
import fetch, { type FetchOptions } from "make-fetch-happen";
import { dirname as pathDirname, join as pathJoin } from "path";
import { assert } from "tsafe";
@ -25,75 +25,32 @@ async function exists(path: string) {
}
}
function ensureArray<T>(arg0: T | T[]) {
return Array.isArray(arg0) ? arg0 : typeof arg0 === "undefined" ? [] : [arg0];
}
function ensureSingleOrNone<T>(arg0: T | T[]) {
if (!Array.isArray(arg0)) return arg0;
if (arg0.length === 0) return undefined;
if (arg0.length === 1) return arg0[0];
throw new Error("Illegal configuration, expected a single value but found multiple: " + arg0.map(String).join(", "));
}
type NPMConfig = Record<string, string | string[]>;
const npmConfigReducer = (cfg: NPMConfig, [key, value]: [string, string]) =>
key in cfg ? { ...cfg, [key]: [...ensureArray(cfg[key]), value] } : { ...cfg, [key]: value };
/**
* Get npm configuration as map
*/
async function getNmpConfig() {
return readNpmConfig().then(parseNpmConfig);
}
async function readNpmConfig() {
async function getNmpConfig(): Promise<Record<string, string>> {
const { stdout } = await exec("npm config get", { encoding: "utf8" });
return stdout;
}
function parseNpmConfig(stdout: string) {
return stdout
.split("\n")
.filter(line => !line.startsWith(";"))
.map(line => line.trim())
.map(line => line.split("=", 2) as [string, string])
.reduce(npmConfigReducer, {} as NPMConfig);
}
function maybeBoolean(arg0: string | undefined) {
return typeof arg0 === "undefined" ? undefined : Boolean(arg0);
}
function chunks<T>(arr: T[], size: number = 2) {
return arr.map((_, i) => i % size == 0 && arr.slice(i, i + size)).filter(Boolean) as T[][];
}
async function readCafile(cafile: string) {
const cafileContent = await readFile(cafile, "utf-8");
return chunks(cafileContent.split(/(-----END CERTIFICATE-----)/), 2).map(ca => ca.join("").replace(/^\n/, "").replace(/\n/g, "\\n"));
.map(line => line.split("=", 2))
.reduce((cfg, [key, value]) => ({ ...cfg, [key]: value }), {});
}
/**
* Get proxy and ssl configuration from npm config files. Note that we don't care about
* Get proxy configuration from npm config files. Note that we don't care about
* proxy config in env vars, because make-fetch-happen will do that for us.
*
* @returns proxy configuration
*/
async function getFetchOptions(): Promise<Pick<FetchOptions, "proxy" | "noProxy" | "strictSSL" | "ca" | "cert">> {
async function getNpmProxyConfig(): Promise<Pick<FetchOptions, "proxy" | "noProxy">> {
const cfg = await getNmpConfig();
const proxy = ensureSingleOrNone(cfg["https-proxy"] ?? cfg["proxy"]);
const proxy = cfg["https-proxy"] ?? cfg["proxy"];
const noProxy = cfg["noproxy"] ?? cfg["no-proxy"];
const strictSSL = maybeBoolean(ensureSingleOrNone(cfg["strict-ssl"]));
const cert = cfg["cert"];
const ca = ensureArray(cfg["ca"] ?? cfg["ca[]"]);
const cafile = ensureSingleOrNone(cfg["cafile"]);
if (typeof cafile !== "undefined" && cafile !== "null") ca.push(...(await readCafile(cafile)));
return { proxy, noProxy, strictSSL, cert, ca: ca.length === 0 ? undefined : ca };
return { proxy, noProxy };
}
export async function downloadAndUnzip(params: { url: string; destDirPath: string; pathOfDirToExtractInArchive?: string }) {
@ -106,8 +63,8 @@ export async function downloadAndUnzip(params: { url: string; destDirPath: strin
const extractDirPath = pathJoin(cacheRoot, "keycloakify", "unzip", `_${downloadHash}`);
if (!(await exists(zipFilePath))) {
const opts = await getFetchOptions();
const response = await fetch(url, opts);
const proxyOpts = await getNpmProxyConfig();
const response = await fetch(url, proxyOpts);
await mkdir(pathDirname(zipFilePath), { "recursive": true });
/**
* The correct way to fix this is to upgrade node-fetch beyond 3.2.5

View File

@ -1,4 +1,4 @@
import { dirname, relative, sep, join } from "path";
import { dirname, relative, sep } from "path";
import { createWriteStream } from "fs";
import walk from "./walk";
@ -48,12 +48,8 @@ export async function jarStream({ groupId, artifactId, version, asyncPathGenerat
for await (const entry of asyncPathGeneratorFn()) {
if ("buffer" in entry) {
zipFile.addBuffer(entry.buffer, entry.zipPath);
} else if ("fsPath" in entry) {
if (entry.fsPath.endsWith(sep)) {
zipFile.addEmptyDirectory(entry.zipPath);
} else {
zipFile.addFile(entry.fsPath, entry.zipPath);
}
} else if ("fsPath" in entry && !entry.fsPath.endsWith(sep)) {
zipFile.addFile(entry.fsPath, entry.zipPath);
}
}
@ -69,23 +65,15 @@ export async function jarStream({ groupId, artifactId, version, asyncPathGenerat
* Create a jar archive, using the resources found at `rootPath` (a directory) and write the
* archive to `targetPath` (a file). Use `groupId`, `artifactId` and `version` to define
* the contents of the pom.properties file which is going to be added to the archive.
* The root directory is expectedto have a conventional maven/gradle folder structure with a
* single `pom.xml` file at the root and a `src/main/resources` directory containing all
* application resources.
*/
export default async function jar({ groupId, artifactId, version, rootPath, targetPath }: JarArgs) {
await mkdir(dirname(targetPath), { recursive: true });
const asyncPathGeneratorFn = async function* (): ZipEntryGenerator {
const resourcesPath = join(rootPath, "src", "main", "resources");
for await (const fsPath of walk(resourcesPath)) {
const zipPath = relative(resourcesPath, fsPath).split(sep).join("/");
for await (const fsPath of walk(rootPath)) {
const zipPath = relative(rootPath, fsPath).split(sep).join("/");
yield { fsPath, zipPath };
}
yield {
fsPath: join(rootPath, "pom.xml"),
zipPath: `META-INF/maven/${groupId}/${artifactId}/pom.xml`
};
};
const zipFile = await jarStream({ groupId, artifactId, version, asyncPathGeneratorFn });

View File

@ -15,7 +15,7 @@ export function usePrepareTemplate(params: {
htmlClassName: string | undefined;
bodyClassName: string | undefined;
}) {
const { doFetchDefaultThemeResources, stylesCommon = [], styles = [], url, scripts = [], htmlClassName, bodyClassName } = params;
const { doFetchDefaultThemeResources, stylesCommon, styles, url, scripts, htmlClassName, bodyClassName } = params;
const [isReady, setReady] = useReducer(() => true, !doFetchDefaultThemeResources);
@ -26,49 +26,36 @@ export function usePrepareTemplate(params: {
let isUnmounted = false;
const removeArray: (() => void)[] = [];
(async () => {
const prLoadedArray: Promise<void>[] = [];
Promise.all(
[
...stylesCommon.map(relativePath => pathJoin(url.resourcesCommonPath, relativePath)),
...styles.map(relativePath => pathJoin(url.resourcesPath, relativePath))
...(stylesCommon ?? []).map(relativePath => pathJoin(url.resourcesCommonPath, relativePath)),
...(styles ?? []).map(relativePath => pathJoin(url.resourcesPath, relativePath))
]
.reverse()
.forEach(href => {
const { prLoaded, remove } = headInsert({
.map(href =>
headInsert({
"type": "css",
"position": "prepend",
href
});
removeArray.push(remove);
prLoadedArray.push(prLoaded);
});
await Promise.all(prLoadedArray);
href,
"position": "prepend"
})
)
).then(() => {
if (isUnmounted) {
return;
}
setReady();
})();
});
scripts.forEach(relativePath => {
const { remove } = headInsert({
(scripts ?? []).forEach(relativePath =>
headInsert({
"type": "javascript",
"src": pathJoin(url.resourcesPath, relativePath)
});
removeArray.push(remove);
});
})
);
return () => {
isUnmounted = true;
removeArray.forEach(remove => remove());
};
}, []);

View File

@ -27,7 +27,6 @@ const UpdateUserProfile = lazy(() => import("keycloakify/login/pages/UpdateUserP
const IdpReviewUserProfile = lazy(() => import("keycloakify/login/pages/IdpReviewUserProfile"));
const UpdateEmail = lazy(() => import("keycloakify/login/pages/UpdateEmail"));
const SelectAuthenticator = lazy(() => import("keycloakify/login/pages/SelectAuthenticator"));
const SamlPostForm = lazy(() => import("keycloakify/login/pages/SamlPostForm"));
export default function Fallback(props: PageProps<KcContext, I18n>) {
const { kcContext, ...rest } = props;
@ -82,8 +81,6 @@ export default function Fallback(props: PageProps<KcContext, I18n>) {
return <UpdateEmail kcContext={kcContext} {...rest} />;
case "select-authenticator.ftl":
return <SelectAuthenticator kcContext={kcContext} {...rest} />;
case "saml-post-form.ftl":
return <SamlPostForm kcContext={kcContext} {...rest} />;
}
assert<Equals<typeof kcContext, never>>(false);
})()}

View File

@ -1,4 +1,4 @@
import type { LoginThemePageId, ThemeType } from "keycloakify/bin/keycloakify/generateFtl";
import type { LoginThemePageId } from "keycloakify/bin/keycloakify/generateFtl";
import { assert } from "tsafe/assert";
import type { Equals } from "tsafe";
import type { MessageKey } from "../i18n/i18n";
@ -32,13 +32,11 @@ export type KcContext =
| KcContext.UpdateUserProfile
| KcContext.IdpReviewUserProfile
| KcContext.UpdateEmail
| KcContext.SelectAuthenticator
| KcContext.SamlPostForm;
| KcContext.SelectAuthenticator;
export declare namespace KcContext {
export type Common = {
keycloakifyVersion: string;
themeType: "login";
url: {
loginAction: string;
resourcesPath: string;
@ -87,16 +85,6 @@ export declare namespace KcContext {
};
};
export type SamlPostForm = Common & {
pageId: "saml-post-form.ftl";
samlPost: {
url: string;
SAMLRequest?: string;
SAMLResponse?: string;
relayState?: string;
};
};
export type Login = Common & {
pageId: "login.ftl";
url: {
@ -345,6 +333,7 @@ export declare namespace KcContext {
totpSecretEncoded: string;
qrUrl: string;
policy: {
supportedApplications: string[];
algorithm: "HmacSHA1" | "HmacSHA256" | "HmacSHA512";
digits: number;
lookAheadWindow: number;
@ -358,7 +347,6 @@ export declare namespace KcContext {
initialCounter: number;
}
);
supportedApplications: string[];
totpSecretQrCode: string;
manualUrl: string;
totpSecret: string;
@ -555,15 +543,4 @@ export declare namespace Validators {
};
}
{
type Got = KcContext["pageId"];
type Expected = LoginThemePageId;
type OnlyInGot = Exclude<Got, Expected>;
type OnlyInExpected = Exclude<Expected, Got>;
assert<Equals<OnlyInGot, never>>();
assert<Equals<OnlyInExpected, never>>();
}
assert<KcContext["themeType"] extends ThemeType ? true : false>();
assert<Equals<KcContext["pageId"], LoginThemePageId>>();

View File

@ -9,8 +9,9 @@ import type { ExtendKcContext } from "./getKcContextFromWindow";
import { getKcContextFromWindow } from "./getKcContextFromWindow";
import { pathJoin } from "keycloakify/bin/tools/pathJoin";
import { pathBasename } from "keycloakify/tools/pathBasename";
import { resourcesCommonDirPathRelativeToPublicDir } from "keycloakify/bin/mockTestingResourcesPath";
import { mockTestingResourcesCommonPath } from "keycloakify/bin/mockTestingResourcesPath";
import { symToStr } from "tsafe/symToStr";
import { loginThemePageIds } from "keycloakify/bin/keycloakify/generateFtl/pageId";
export function createGetKcContext<KcContextExtension extends { pageId: string } = never>(params?: {
mockData?: readonly DeepPartial<ExtendKcContext<KcContextExtension>>[];
@ -32,7 +33,13 @@ export function createGetKcContext<KcContextExtension extends { pageId: string }
if (mockPageId !== undefined && realKcContext === undefined) {
//TODO maybe trow if no mock fo custom page
console.log(`%cKeycloakify: ${symToStr({ mockPageId })} set to ${mockPageId}.`, "background: red; color: yellow; font-size: medium");
console.log(
[
`%cKeycloakify: ${symToStr({ mockPageId })} set to ${mockPageId}.`,
`If assets are missing make sure you have built your Keycloak theme at least once.`
].join(" "),
"background: red; color: yellow; font-size: medium"
);
const kcContextDefaultMock = kcContextMocks.find(({ pageId }) => pageId === mockPageId);
@ -144,14 +151,14 @@ export function createGetKcContext<KcContextExtension extends { pageId: string }
return { "kcContext": undefined as any };
}
if (realKcContext.themeType !== "login") {
if (id<readonly string[]>(loginThemePageIds).indexOf(realKcContext.pageId) < 0 && !("login" in realKcContext)) {
return { "kcContext": undefined as any };
}
{
const { url } = realKcContext;
url.resourcesCommonPath = pathJoin(url.resourcesPath, pathBasename(resourcesCommonDirPathRelativeToPublicDir));
url.resourcesCommonPath = pathJoin(url.resourcesPath, pathBasename(mockTestingResourcesCommonPath));
}
return { "kcContext": realKcContext as any };

View File

@ -1,10 +1,8 @@
import "minimal-polyfills/Object.fromEntries";
import type { KcContext, Attribute } from "./KcContext";
import { resourcesCommonDirPathRelativeToPublicDir, resourcesDirPathRelativeToPublicDir } from "keycloakify/bin/mockTestingResourcesPath";
import { mockTestingResourcesCommonPath, mockTestingResourcesPath } from "keycloakify/bin/mockTestingResourcesPath";
import { pathJoin } from "keycloakify/bin/tools/pathJoin";
import { id } from "tsafe/id";
import { assert, type Equals } from "tsafe/assert";
import type { LoginThemePageId } from "keycloakify/bin/keycloakify/generateFtl";
const PUBLIC_URL = process.env["PUBLIC_URL"] ?? "/";
@ -104,11 +102,10 @@ const attributesByName = Object.fromEntries(attributes.map(attribute => [attribu
export const kcContextCommonMock: KcContext.Common = {
"keycloakifyVersion": "0.0.0",
"themeType": "login",
"url": {
"loginAction": "#",
"resourcesPath": pathJoin(PUBLIC_URL, resourcesDirPathRelativeToPublicDir),
"resourcesCommonPath": pathJoin(PUBLIC_URL, resourcesCommonDirPathRelativeToPublicDir),
"resourcesPath": pathJoin(PUBLIC_URL, mockTestingResourcesPath),
"resourcesCommonPath": pathJoin(PUBLIC_URL, mockTestingResourcesCommonPath),
"loginRestartFlowUrl": "/auth/realms/myrealm/login-actions/restart?client_id=account&tab_id=HoAx28ja4xg",
"loginUrl": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg"
},
@ -246,7 +243,7 @@ const loginUrl = {
"registrationUrl": "/auth/realms/myrealm/login-actions/registration?client_id=account&tab_id=HoAx28ja4xg"
};
export const kcContextMocks = [
export const kcContextMocks: KcContext[] = [
id<KcContext.Login>({
...kcContextCommonMock,
"pageId": "login.ftl",
@ -456,8 +453,8 @@ export const kcContextMocks = [
manualUrl: "#",
totpSecret: "G4nsI8lQagRMUchH8jEG",
otpCredentials: [],
supportedApplications: ["FreeOTP", "Google Authenticator"],
policy: {
supportedApplications: ["FreeOTP", "Google Authenticator"],
algorithm: "HmacSHA1",
digits: 6,
lookAheadWindow: 1,
@ -522,27 +519,5 @@ export const kcContextMocks = [
}
]
}
}),
id<KcContext.SamlPostForm>({
...kcContextCommonMock,
pageId: "saml-post-form.ftl",
"samlPost": {
"url": "https://saml-post-url"
}
}),
id<KcContext.LoginPageExpired>({
...kcContextCommonMock,
pageId: "login-page-expired.ftl"
})
];
{
type Got = (typeof kcContextMocks)[number]["pageId"];
type Expected = LoginThemePageId;
type OnlyInGot = Exclude<Got, Expected>;
type OnlyInExpected = Exclude<Expected, Got>;
assert<Equals<OnlyInGot, never>>();
assert<Equals<OnlyInExpected, never>>();
}

View File

@ -3,7 +3,6 @@ import type { PageProps } from "keycloakify/login/pages/PageProps";
import { useGetClassName } from "keycloakify/login/lib/useGetClassName";
import type { KcContext } from "../kcContext";
import type { I18n } from "../i18n";
import { MessageKey } from "keycloakify/login/i18n/i18n";
export default function LoginConfigTotp(props: PageProps<Extract<KcContext, { pageId: "login-config-totp.ftl" }>, I18n>) {
const { kcContext, i18n, doUseDefaultCss, Template, classes } = props;
@ -17,7 +16,7 @@ export default function LoginConfigTotp(props: PageProps<Extract<KcContext, { pa
const { msg, msgStr } = i18n;
const algToKeyUriAlg: Record<(typeof kcContext)["totp"]["policy"]["algorithm"], string> = {
const algToKeyUriAlg: Record<KcContext.LoginConfigTotp["totp"]["policy"]["algorithm"], string> = {
"HmacSHA1": "SHA1",
"HmacSHA256": "SHA256",
"HmacSHA512": "SHA512"
@ -31,8 +30,8 @@ export default function LoginConfigTotp(props: PageProps<Extract<KcContext, { pa
<p>{msg("loginTotpStep1")}</p>
<ul id="kc-totp-supported-apps">
{totp.supportedApplications.map(app => (
<li>{msgStr(app as MessageKey, app)}</li>
{totp.policy.supportedApplications.map(app => (
<li>{app}</li>
))}
</ul>
</li>
@ -170,7 +169,7 @@ export default function LoginConfigTotp(props: PageProps<Extract<KcContext, { pa
name="cancel-aia"
value="true"
>
{msg("doCancel")}
${msg("doCancel")}
</button>
</>
) : (

View File

@ -22,24 +22,17 @@ export default function LoginOtp(props: PageProps<Extract<KcContext, { pageId: "
useEffect(() => {
let isCleanedUp = false;
const { prLoaded, remove } = headInsert({
headInsert({
"type": "javascript",
"src": pathJoin(kcContext.url.resourcesCommonPath, "node_modules/jquery/dist/jquery.min.js")
});
(async () => {
await prLoaded;
if (isCleanedUp) {
return;
}
}).then(() => {
if (isCleanedUp) return;
evaluateInlineScript();
})();
});
return () => {
isCleanedUp = true;
remove();
};
}, []);

View File

@ -11,7 +11,7 @@ export default function LoginVerifyEmail(props: PageProps<Extract<KcContext, { p
return (
<Template {...{ kcContext, i18n, doUseDefaultCss, classes }} displayMessage={false} headerNode={msg("emailVerifyTitle")}>
<p className="instruction">{msg("emailVerifyInstruction1", user?.email ?? "")}</p>
<p className="instruction">{msg("emailVerifyInstruction1", user?.email)}</p>
<p className="instruction">
{msg("emailVerifyInstruction2")}
<br />

View File

@ -14,13 +14,11 @@ export default function RegisterUserProfile(props: PageProps<Extract<KcContext,
classes
});
const { url, messagesPerField, recaptchaRequired, recaptchaSiteKey, realm } = kcContext;
realm.registrationEmailAsUsername;
const { url, messagesPerField, recaptchaRequired, recaptchaSiteKey } = kcContext;
const { msg, msgStr } = i18n;
const [isFormSubmittable, setIsFormSubmittable] = useState(false);
const [isFomSubmittable, setIsFomSubmittable] = useState(false);
return (
<Template
@ -32,7 +30,7 @@ export default function RegisterUserProfile(props: PageProps<Extract<KcContext,
<form id="kc-register-form" className={getClassName("kcFormClass")} action={url.registrationAction} method="post">
<UserProfileFormFields
kcContext={kcContext}
onIsFormSubmittableValueChange={setIsFormSubmittable}
onIsFormSubmittableValueChange={setIsFomSubmittable}
i18n={i18n}
getClassName={getClassName}
/>
@ -62,7 +60,7 @@ export default function RegisterUserProfile(props: PageProps<Extract<KcContext,
)}
type="submit"
value={msgStr("doRegister")}
disabled={!isFormSubmittable}
disabled={!isFomSubmittable}
/>
</div>
</div>

View File

@ -1,29 +0,0 @@
import { useEffect } from "react";
import type { PageProps } from "keycloakify/login/pages/PageProps";
import type { KcContext } from "../kcContext";
import type { I18n } from "../i18n";
export default function SamlPostForm(props: PageProps<Extract<KcContext, { pageId: "saml-post-form.ftl" }>, I18n>) {
const { kcContext, i18n, doUseDefaultCss, Template, classes } = props;
const { msgStr, msg } = i18n;
const { samlPost } = kcContext;
useEffect(() => {
document.forms[0].submit();
}, [samlPost]);
return (
<Template {...{ kcContext, i18n, doUseDefaultCss, classes }} displayMessage={false} headerNode={msg("saml.post-form.title")}>
<p>{msg("saml.post-form.message")}</p>
<form name="saml-post-binding" method="post" action={samlPost.url}>
{samlPost.SAMLRequest && <input type="hidden" name="SAMLRequest" value={samlPost.SAMLRequest} />}
{samlPost.SAMLResponse && <input type="hidden" name="SAMLResponse" value={samlPost.SAMLResponse} />}
{samlPost.relayState && <input type="hidden" name="RelayState" value={samlPost.relayState} />}
<noscript>
<p>{msg("saml.post-form.js-disabled")}</p>
<input type="submit" value={msgStr("doContinue")} />
</noscript>
</form>
</Template>
);
}

View File

@ -7,9 +7,6 @@ import type { PageProps } from "keycloakify/login/pages/PageProps";
import { useGetClassName } from "keycloakify/login/lib/useGetClassName";
import type { KcContext } from "../kcContext";
import type { I18n } from "../i18n";
import { assert } from "tsafe/assert";
import { is } from "tsafe/is";
import { typeGuard } from "tsafe/typeGuard";
export default function WebauthnAuthenticate(props: PageProps<Extract<KcContext, { pageId: "webauthn-authenticate.ftl" }>, I18n>) {
const { kcContext, i18n, doUseDefaultCss, Template, classes } = props;
@ -24,24 +21,10 @@ export default function WebauthnAuthenticate(props: PageProps<Extract<KcContext,
const createTimeout = Number(kcContext.createTimeout);
const isUserIdentified = kcContext.isUserIdentified == "true";
const formElementRef = useRef<HTMLFormElement>(null);
const webAuthnAuthenticate = useConstCallback(async () => {
if (!isUserIdentified) {
return;
}
const submitForm = async (): Promise<void> => {
const formElement = formElementRef.current;
if (formElement === null) {
await new Promise(resolve => setTimeout(resolve, 100));
return submitForm();
}
formElement.submit();
};
const allowCredentials = authenticators.authenticators.map(
authenticator =>
({
@ -74,36 +57,30 @@ export default function WebauthnAuthenticate(props: PageProps<Extract<KcContext,
}
try {
const result = await navigator.credentials.get({ publicKey });
if (!result || result.type != "public-key") {
return;
}
assert(is<PublicKeyCredential>(result));
if (!("authenticatorData" in result.response)) {
return;
}
const response = result.response;
const resultRaw = await navigator.credentials.get({ publicKey });
if (!resultRaw || resultRaw.type != "public-key") return;
const result = resultRaw as PublicKeyCredential;
if (!("authenticatorData" in result.response)) return;
const response = result.response as AuthenticatorAssertionResponse;
const clientDataJSON = response.clientDataJSON;
assert(
typeGuard<AuthenticatorAssertionResponse>(response, "signature" in response && response.authenticatorData instanceof ArrayBuffer),
"response not an AuthenticatorAssertionResponse"
);
const authenticatorData = response.authenticatorData;
const signature = response.signature;
setClientDataJSON(base64url.stringify(new Uint8Array(clientDataJSON), { "pad": false }));
setAuthenticatorData(base64url.stringify(new Uint8Array(authenticatorData), { "pad": false }));
setSignature(base64url.stringify(new Uint8Array(signature), { "pad": false }));
setClientDataJSON(base64url.stringify(new Uint8Array(clientDataJSON), { pad: false }));
setAuthenticatorData(base64url.stringify(new Uint8Array(authenticatorData), { pad: false }));
setSignature(base64url.stringify(new Uint8Array(signature), { pad: false }));
setCredentialId(result.id);
setUserHandle(base64url.stringify(new Uint8Array(response.userHandle!), { "pad": false }));
setUserHandle(base64url.stringify(new Uint8Array(response.userHandle!), { pad: false }));
submitForm();
} catch (err) {
setError(String(err));
submitForm();
}
});
submitForm();
const webAuthForm = useRef<HTMLFormElement>(null);
const submitForm = useConstCallback(() => {
webAuthForm.current!.submit();
});
const [clientDataJSON, setClientDataJSON] = useState("");
@ -116,7 +93,7 @@ export default function WebauthnAuthenticate(props: PageProps<Extract<KcContext,
return (
<Template {...{ kcContext, i18n, doUseDefaultCss, classes }} headerNode={msg("webauthn-login-title")}>
<div id="kc-form-webauthn" className={getClassName("kcFormClass")}>
<form id="webauth" action={url.loginAction} ref={formElementRef} method="post">
<form id="webauth" action={url.loginAction} ref={webAuthForm} method="post">
<input type="hidden" id="clientDataJSON" name="clientDataJSON" value={clientDataJSON} />
<input type="hidden" id="authenticatorData" name="authenticatorData" value={authenticatorData} />
<input type="hidden" id="signature" name="signature" value={signature} />

View File

@ -12,7 +12,7 @@ export function headInsert(
type: "javascript";
src: string;
}
): { remove: () => void; prLoaded: Promise<void> } {
) {
const htmlElement = document.createElement(
(() => {
switch (params.type) {
@ -66,8 +66,5 @@ export function headInsert(
})()
](htmlElement);
return {
"prLoaded": dLoaded.pr,
"remove": () => htmlElement.remove()
};
return dLoaded.pr;
}

View File

@ -1,27 +0,0 @@
import React, { lazy, Suspense } from "react";
import Fallback from "../../dist/account";
import type { KcContext } from "./kcContext";
import { useI18n } from "./i18n";
const DefaultTemplate = lazy(() => import("../../dist/account/Template"));
export default function KcApp(props: { kcContext: KcContext }) {
const { kcContext } = props;
const i18n = useI18n({ kcContext });
if (i18n === null) {
return null;
}
return (
<Suspense>
{(() => {
switch (kcContext.pageId) {
default:
return <Fallback {...{ kcContext, i18n }} Template={DefaultTemplate} doUseDefaultCss={true} />;
}
})()}
</Suspense>
);
}

View File

@ -1,19 +0,0 @@
import React from "react";
import { getKcContext, type KcContext } from "./kcContext";
import KcApp from "./KcApp";
import type { DeepPartial } from "../../dist/tools/DeepPartial";
export function createPageStory<PageId extends KcContext["pageId"]>(params: { pageId: PageId }) {
const { pageId } = params;
function PageStory(params: { kcContext?: DeepPartial<Extract<KcContext, { pageId: PageId }>> }) {
const { kcContext } = getKcContext({
mockPageId: pageId,
storyPartialKcContext: params.kcContext
});
return <KcApp kcContext={kcContext} />;
}
return { PageStory };
}

View File

@ -1,5 +0,0 @@
import { createUseI18n } from "../../dist/account";
export const { useI18n } = createUseI18n({});
export type I18n = NonNullable<ReturnType<typeof useI18n>>;

View File

@ -1,7 +0,0 @@
import { createGetKcContext } from "../../dist/account";
export const { getKcContext } = createGetKcContext();
const { kcContext } = getKcContext();
export type KcContext = NonNullable<typeof kcContext>;

View File

@ -1,24 +0,0 @@
import React from "react";
import type { ComponentMeta } from "@storybook/react";
import { createPageStory } from "../createPageStory";
const pageId = "account.ftl";
const { PageStory } = createPageStory({ pageId });
const meta: ComponentMeta<any> = {
title: `account/${pageId}`,
component: PageStory,
parameters: {
viewMode: "story",
previewTabs: {
"storybook/docs/panel": {
hidden: true
}
}
}
};
export default meta;
export const Default = () => <PageStory />;

View File

@ -1,31 +0,0 @@
import React from "react";
import type { ComponentMeta } from "@storybook/react";
import { createPageStory } from "../createPageStory";
const pageId = "password.ftl";
const { PageStory } = createPageStory({ pageId });
const meta: ComponentMeta<any> = {
title: `account/${pageId}`,
component: PageStory,
parameters: {
viewMode: "story",
previewTabs: {
"storybook/docs/panel": {
hidden: true
}
}
}
};
export default meta;
export const Default = () => <PageStory />;
export const WithNoMessage = () => (
<PageStory
kcContext={{
message: undefined
}}
/>
);

9
stories/global.d.ts vendored
View File

@ -1,9 +0,0 @@
declare module "*.png" {
const _default: string;
export default _default;
}
declare module "*.md" {
const _default: string;
export default _default;
}

View File

@ -1,5 +1,6 @@
import { Meta } from "@storybook/addon-docs";
import { KeycloakifyRotatingLogo } from "./KeycloakifyRotatingLogo";
import { useDarkMode } from "storybook-dark-mode";
import "./assets/fonts/WorkSans/font.css";
<Meta
title="Introduction"
@ -15,15 +16,13 @@ import { KeycloakifyRotatingLogo } from "./KeycloakifyRotatingLogo";
/>
<div style={{ "margin": "0 auto", "maxWidth": "700px", "textAlign": "center" }}>
<div style={{ "display": "flex", "justifyContent": "center" }}>
<KeycloakifyRotatingLogo style={{ "width": 400 }} />
</div>
<img src="preview.png" />
<h1><a href="#">Keycloakify </a> Storybook</h1>
<p>
This website showcases all the Keycloak user-facing pages that can be customized using Keycloakify.
The storybook serves as a comprehensive reference to help you determine which pages you would like to personalize.
Keep in mind that customizing the <a href="https://github.com/keycloakify/keycloakify-starter/blob/main/src/keycloak-theme/login/Template.tsx"><code>Template</code></a> component alone will already cover 90% of your customization needs.
Keep in mind that customizing the <code>Template</code> component alone will already cover 90% of your customization needs.
</p>
<p>
@ -32,4 +31,4 @@ Simply refer to <a href="https://docs.keycloakify.dev/limitations#i-have-establi
</p>
</div>
</div>

View File

@ -1,61 +0,0 @@
import React from "react";
import { memo, useState } from "react";
import { useConstCallback } from "powerhooks";
import { keyframes } from "tss-react";
import keycloakifyLogoHeroMovingPngUrl from "./keycloakify-logo-hero-moving.png";
import keycloakifyLogoHeroStillPngUrl from "./keycloakify-logo-hero-still.png";
import { makeStyles } from "./tss";
export type Props = {
style?: React.CSSProperties;
id?: string;
onLoad?: () => void;
};
export const KeycloakifyRotatingLogo = memo((props: Props) => {
const { id, style, onLoad: onLoadProp } = props;
const [isImageLoaded, setIsImageLoaded] = useState(false);
const onLoad = useConstCallback(() => {
setIsImageLoaded(true);
onLoadProp?.();
});
const { classes } = useStyles({
isImageLoaded
});
return (
<div id={id} className={classes.root} style={style}>
<img className={classes.rotatingImg} onLoad={onLoad} src={keycloakifyLogoHeroMovingPngUrl} alt={"Rotating react logo"} />
<img className={classes.stillImg} src={keycloakifyLogoHeroStillPngUrl} alt={"keyhole"} />
</div>
);
});
const useStyles = makeStyles<{ isImageLoaded: boolean }>({
"name": { KeycloakifyRotatingLogo }
})((_theme, { isImageLoaded }) => ({
"root": {
"position": "relative"
},
"rotatingImg": {
"animation": `${keyframes({
"from": {
"transform": "rotate(0deg)"
},
"to": {
"transform": "rotate(360deg)"
}
})} infinite 20s linear`,
"width": isImageLoaded ? "100%" : undefined,
"height": isImageLoaded ? "auto" : undefined
},
"stillImg": {
"position": "absolute",
"top": "0",
"left": "0",
"width": isImageLoaded ? "100%" : undefined,
"height": isImageLoaded ? "auto" : undefined
}
}));

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.9 KiB

View File

@ -1,5 +0,0 @@
import { createMakeAndWithStyles } from "tss-react";
export const { makeStyles, useStyles } = createMakeAndWithStyles({
"useTheme": () => ({})
});

View File

@ -1,50 +0,0 @@
import React, { lazy, Suspense } from "react";
import Fallback from "../../dist/login";
import type { KcContext } from "./kcContext";
import { useI18n } from "./i18n";
import { useDownloadTerms } from "../../dist/login/lib/useDownloadTerms";
import tos_en_url from "./tos_en.md";
import tos_fr_url from "./tos_fr.md";
const DefaultTemplate = lazy(() => import("../../dist/login/Template"));
export default function KcApp(props: { kcContext: KcContext }) {
const { kcContext } = props;
const i18n = useI18n({ kcContext });
useDownloadTerms({
"kcContext": kcContext as any,
"downloadTermMarkdown": async ({ currentLanguageTag }) => {
const resource = (() => {
switch (currentLanguageTag) {
case "fr":
return tos_fr_url;
default:
return tos_en_url;
}
})();
// webpack5 (used via storybook) loads markdown as string, not url
if (resource.includes("\n")) return resource;
const response = await fetch(resource);
return response.text();
}
});
if (i18n === null) {
return null;
}
return (
<Suspense>
{(() => {
switch (kcContext.pageId) {
default:
return <Fallback {...{ kcContext, i18n }} Template={DefaultTemplate} doUseDefaultCss={true} />;
}
})()}
</Suspense>
);
}

View File

@ -1,19 +0,0 @@
import React from "react";
import { getKcContext, type KcContext } from "./kcContext";
import KcApp from "./KcApp";
import type { DeepPartial } from "../../dist/tools/DeepPartial";
export function createPageStory<PageId extends KcContext["pageId"]>(params: { pageId: PageId }) {
const { pageId } = params;
function PageStory(params: { kcContext?: DeepPartial<Extract<KcContext, { pageId: PageId }>> }) {
const { kcContext } = getKcContext({
mockPageId: pageId,
storyPartialKcContext: params.kcContext
});
return <KcApp kcContext={kcContext} />;
}
return { PageStory };
}

View File

@ -1,5 +0,0 @@
import { createUseI18n } from "../../dist/login";
export const { useI18n } = createUseI18n({});
export type I18n = NonNullable<ReturnType<typeof useI18n>>;

View File

@ -1,7 +0,0 @@
import { createGetKcContext } from "../../dist/login";
export const { getKcContext } = createGetKcContext();
const { kcContext } = getKcContext();
export type KcContext = NonNullable<typeof kcContext>;

View File

@ -1,32 +0,0 @@
import React from "react";
import type { ComponentMeta } from "@storybook/react";
import { createPageStory } from "../createPageStory";
const pageId = "error.ftl";
const { PageStory } = createPageStory({ pageId });
const meta: ComponentMeta<any> = {
title: `login/${pageId}`,
component: PageStory,
parameters: {
viewMode: "story",
previewTabs: {
"storybook/docs/panel": {
hidden: true
}
}
}
};
export default meta;
export const Default = () => <PageStory />;
export const WithAnotherMessage = () => (
<PageStory
kcContext={{
message: { summary: "With another error message" }
}}
/>
);

View File

@ -1,24 +0,0 @@
import React from "react";
import type { ComponentMeta } from "@storybook/react";
import { createPageStory } from "../createPageStory";
const pageId = "idp-review-user-profile.ftl";
const { PageStory } = createPageStory({ pageId });
const meta: ComponentMeta<any> = {
title: `login/${pageId}`,
component: PageStory,
parameters: {
viewMode: "story",
previewTabs: {
"storybook/docs/panel": {
"hidden": true
}
}
}
};
export default meta;
export const Default = () => <PageStory />;

View File

@ -1,24 +0,0 @@
import React from "react";
import type { ComponentMeta } from "@storybook/react";
import { createPageStory } from "../createPageStory";
const pageId = "info.ftl";
const { PageStory } = createPageStory({ pageId });
const meta: ComponentMeta<any> = {
title: `login/${pageId}`,
component: PageStory,
parameters: {
viewMode: "story",
previewTabs: {
"storybook/docs/panel": {
"hidden": true
}
}
}
};
export default meta;
export const Default = () => <PageStory />;

View File

@ -1,105 +0,0 @@
import React from "react";
import type { ComponentMeta } from "@storybook/react";
import { createPageStory } from "../createPageStory";
const pageId = "login.ftl";
const { PageStory } = createPageStory({ pageId });
const meta: ComponentMeta<any> = {
title: `login/${pageId}`,
component: PageStory,
parameters: {
viewMode: "story",
previewTabs: {
"storybook/docs/panel": {
"hidden": true
}
}
}
};
export default meta;
export const Default = () => <PageStory />;
export const WithoutPasswordField = () => (
<PageStory
kcContext={{
realm: { password: false }
}}
/>
);
export const WithoutRegistration = () => (
<PageStory
kcContext={{
realm: { registrationAllowed: false }
}}
/>
);
export const WithoutRememberMe = () => (
<PageStory
kcContext={{
realm: { rememberMe: false }
}}
/>
);
export const WithoutPasswordReset = () => (
<PageStory
kcContext={{
realm: { resetPasswordAllowed: false }
}}
/>
);
export const WithEmailAsUsername = () => (
<PageStory
kcContext={{
realm: { loginWithEmailAllowed: false }
}}
/>
);
export const WithPresetUsername = () => (
<PageStory
kcContext={{
login: { username: "max.mustermann@mail.com" }
}}
/>
);
export const WithImmutablePresetUsername = () => (
<PageStory
kcContext={{
login: { username: "max.mustermann@mail.com" },
usernameEditDisabled: true
}}
/>
);
export const WithSocialProviders = () => (
<PageStory
kcContext={{
social: {
displayInfo: true,
providers: [
{ loginUrl: "google", alias: "google", providerId: "google", displayName: "Google" },
{ loginUrl: "microsoft", alias: "microsoft", providerId: "microsoft", displayName: "Microsoft" },
{ loginUrl: "facebook", alias: "facebook", providerId: "facebook", displayName: "Facebook" },
{ loginUrl: "instagram", alias: "instagram", providerId: "instagram", displayName: "Instagram" },
{ loginUrl: "twitter", alias: "twitter", providerId: "twitter", displayName: "Twitter" },
{ loginUrl: "linkedin", alias: "linkedin", providerId: "linkedin", displayName: "LinkedIn" },
{ loginUrl: "stackoverflow", alias: "stackoverflow", providerId: "stackoverflow", displayName: "Stackoverflow" },
{ loginUrl: "github", alias: "github", providerId: "github", displayName: "Github" },
{ loginUrl: "gitlab", alias: "gitlab", providerId: "gitlab", displayName: "Gitlab" },
{ loginUrl: "bitbucket", alias: "bitbucket", providerId: "bitbucket", displayName: "Bitbucket" },
{ loginUrl: "paypal", alias: "paypal", providerId: "paypal", displayName: "PayPal" },
{ loginUrl: "openshift", alias: "openshift", providerId: "openshift", displayName: "OpenShift" }
]
}
}}
/>
);

View File

@ -1,24 +0,0 @@
import React from "react";
import type { ComponentMeta } from "@storybook/react";
import { createPageStory } from "../createPageStory";
const pageId = "login-config-totp.ftl";
const { PageStory } = createPageStory({ pageId });
const meta: ComponentMeta<any> = {
title: `login/${pageId}`,
component: PageStory,
parameters: {
viewMode: "story",
previewTabs: {
"storybook/docs/panel": {
"hidden": true
}
}
}
};
export default meta;
export const Default = () => <PageStory />;

View File

@ -1,24 +0,0 @@
import React from "react";
import type { ComponentMeta } from "@storybook/react";
import { createPageStory } from "../createPageStory";
const pageId = "login-idp-link-confirm.ftl";
const { PageStory } = createPageStory({ pageId });
const meta: ComponentMeta<any> = {
title: `login/${pageId}`,
component: PageStory,
parameters: {
viewMode: "story",
previewTabs: {
"storybook/docs/panel": {
"hidden": true
}
}
}
};
export default meta;
export const Default = () => <PageStory />;

View File

@ -1,24 +0,0 @@
import React from "react";
import type { ComponentMeta } from "@storybook/react";
import { createPageStory } from "../createPageStory";
const pageId = "login-idp-link-email.ftl";
const { PageStory } = createPageStory({ pageId });
const meta: ComponentMeta<any> = {
title: `login/${pageId}`,
component: PageStory,
parameters: {
viewMode: "story",
previewTabs: {
"storybook/docs/panel": {
"hidden": true
}
}
}
};
export default meta;
export const Default = () => <PageStory />;

View File

@ -1,24 +0,0 @@
import React from "react";
import type { ComponentMeta } from "@storybook/react";
import { createPageStory } from "../createPageStory";
const pageId = "login-otp.ftl";
const { PageStory } = createPageStory({ pageId });
const meta: ComponentMeta<any> = {
title: `login/${pageId}`,
component: PageStory,
parameters: {
viewMode: "story",
previewTabs: {
"storybook/docs/panel": {
"hidden": true
}
}
}
};
export default meta;
export const Default = () => <PageStory />;

View File

@ -1,24 +0,0 @@
import React from "react";
import type { ComponentMeta } from "@storybook/react";
import { createPageStory } from "../createPageStory";
const pageId = "login-page-expired.ftl";
const { PageStory } = createPageStory({ pageId });
const meta: ComponentMeta<any> = {
title: `login/${pageId}`,
component: PageStory,
parameters: {
viewMode: "story",
previewTabs: {
"storybook/docs/panel": {
"hidden": true
}
}
}
};
export default meta;
export const Default = () => <PageStory />;

View File

@ -1,24 +0,0 @@
import React from "react";
import type { ComponentMeta } from "@storybook/react";
import { createPageStory } from "../createPageStory";
const pageId = "login-password.ftl";
const { PageStory } = createPageStory({ pageId });
const meta: ComponentMeta<any> = {
title: `login/${pageId}`,
component: PageStory,
parameters: {
viewMode: "story",
previewTabs: {
"storybook/docs/panel": {
"hidden": true
}
}
}
};
export default meta;
export const Default = () => <PageStory />;

View File

@ -1,24 +0,0 @@
import React from "react";
import type { ComponentMeta } from "@storybook/react";
import { createPageStory } from "../createPageStory";
const pageId = "login-reset-password.ftl";
const { PageStory } = createPageStory({ pageId });
const meta: ComponentMeta<any> = {
title: `login/${pageId}`,
component: PageStory,
parameters: {
viewMode: "story",
previewTabs: {
"storybook/docs/panel": {
"hidden": true
}
}
}
};
export default meta;
export const Default = () => <PageStory />;

View File

@ -1,24 +0,0 @@
import React from "react";
import type { ComponentMeta } from "@storybook/react";
import { createPageStory } from "../createPageStory";
const pageId = "login-update-password.ftl";
const { PageStory } = createPageStory({ pageId });
const meta: ComponentMeta<any> = {
title: `login/${pageId}`,
component: PageStory,
parameters: {
viewMode: "story",
previewTabs: {
"storybook/docs/panel": {
"hidden": true
}
}
}
};
export default meta;
export const Default = () => <PageStory />;

View File

@ -1,24 +0,0 @@
import React from "react";
import type { ComponentMeta } from "@storybook/react";
import { createPageStory } from "../createPageStory";
const pageId = "login-update-profile.ftl";
const { PageStory } = createPageStory({ pageId });
const meta: ComponentMeta<any> = {
title: `login/${pageId}`,
component: PageStory,
parameters: {
viewMode: "story",
previewTabs: {
"storybook/docs/panel": {
"hidden": true
}
}
}
};
export default meta;
export const Default = () => <PageStory />;

View File

@ -1,24 +0,0 @@
import React from "react";
import type { ComponentMeta } from "@storybook/react";
import { createPageStory } from "../createPageStory";
const pageId = "login-username.ftl";
const { PageStory } = createPageStory({ pageId });
const meta: ComponentMeta<any> = {
title: `login/${pageId}`,
component: PageStory,
parameters: {
viewMode: "story",
previewTabs: {
"storybook/docs/panel": {
"hidden": true
}
}
}
};
export default meta;
export const Default = () => <PageStory />;

View File

@ -1,24 +0,0 @@
import React from "react";
import type { ComponentMeta } from "@storybook/react";
import { createPageStory } from "../createPageStory";
const pageId = "login-verify-email.ftl";
const { PageStory } = createPageStory({ pageId });
const meta: ComponentMeta<any> = {
title: `login/${pageId}`,
component: PageStory,
parameters: {
viewMode: "story",
previewTabs: {
"storybook/docs/panel": {
"hidden": true
}
}
}
};
export default meta;
export const Default = () => <PageStory />;

View File

@ -1,24 +0,0 @@
import React from "react";
import type { ComponentMeta } from "@storybook/react";
import { createPageStory } from "../createPageStory";
const pageId = "logout-confirm.ftl";
const { PageStory } = createPageStory({ pageId });
const meta: ComponentMeta<any> = {
title: `login/${pageId}`,
component: PageStory,
parameters: {
viewMode: "story",
previewTabs: {
"storybook/docs/panel": {
"hidden": true
}
}
}
};
export default meta;
export const Default = () => <PageStory />;

View File

@ -1,24 +0,0 @@
import React from "react";
import type { ComponentMeta } from "@storybook/react";
import { createPageStory } from "../createPageStory";
const pageId = "register.ftl";
const { PageStory } = createPageStory({ pageId });
const meta: ComponentMeta<any> = {
title: `login/${pageId}`,
component: PageStory,
parameters: {
viewMode: "story",
previewTabs: {
"storybook/docs/panel": {
"hidden": true
}
}
}
};
export default meta;
export const Default = () => <PageStory />;

View File

@ -1,24 +0,0 @@
import React from "react";
import type { ComponentMeta } from "@storybook/react";
import { createPageStory } from "../createPageStory";
const pageId = "register-user-profile.ftl";
const { PageStory } = createPageStory({ pageId });
const meta: ComponentMeta<any> = {
title: `login/${pageId}`,
component: PageStory,
parameters: {
viewMode: "story",
previewTabs: {
"storybook/docs/panel": {
"hidden": true
}
}
}
};
export default meta;
export const Default = () => <PageStory />;

View File

@ -1,24 +0,0 @@
import React from "react";
import type { ComponentMeta } from "@storybook/react";
import { createPageStory } from "../createPageStory";
const pageId = "saml-post-form.ftl";
const { PageStory } = createPageStory({ pageId });
const meta: ComponentMeta<any> = {
title: `login/${pageId}`,
component: PageStory,
parameters: {
viewMode: "story",
previewTabs: {
"storybook/docs/panel": {
"hidden": true
}
}
}
};
export default meta;
export const Default = () => <PageStory />;

View File

@ -1,24 +0,0 @@
import React from "react";
import type { ComponentMeta } from "@storybook/react";
import { createPageStory } from "../createPageStory";
const pageId = "select-authenticator.ftl";
const { PageStory } = createPageStory({ pageId });
const meta: ComponentMeta<any> = {
title: `login/${pageId}`,
component: PageStory,
parameters: {
viewMode: "story",
previewTabs: {
"storybook/docs/panel": {
"hidden": true
}
}
}
};
export default meta;
export const Default = () => <PageStory />;

View File

@ -1,24 +0,0 @@
import React from "react";
import type { ComponentMeta } from "@storybook/react";
import { createPageStory } from "../createPageStory";
const pageId = "terms.ftl";
const { PageStory } = createPageStory({ pageId });
const meta: ComponentMeta<any> = {
title: `login/${pageId}`,
component: PageStory,
parameters: {
viewMode: "story",
previewTabs: {
"storybook/docs/panel": {
"hidden": true
}
}
}
};
export default meta;
export const Default = () => <PageStory />;

View File

@ -1,24 +0,0 @@
import React from "react";
import type { ComponentMeta } from "@storybook/react";
import { createPageStory } from "../createPageStory";
const pageId = "update-email.ftl";
const { PageStory } = createPageStory({ pageId });
const meta: ComponentMeta<any> = {
title: `login/${pageId}`,
component: PageStory,
parameters: {
viewMode: "story",
previewTabs: {
"storybook/docs/panel": {
"hidden": true
}
}
}
};
export default meta;
export const Default = () => <PageStory />;

View File

@ -1,24 +0,0 @@
import React from "react";
import type { ComponentMeta } from "@storybook/react";
import { createPageStory } from "../createPageStory";
const pageId = "update-user-profile.ftl";
const { PageStory } = createPageStory({ pageId });
const meta: ComponentMeta<any> = {
title: `login/${pageId}`,
component: PageStory,
parameters: {
viewMode: "story",
previewTabs: {
"storybook/docs/panel": {
"hidden": true
}
}
}
};
export default meta;
export const Default = () => <PageStory />;

View File

@ -1,24 +0,0 @@
import React from "react";
import type { ComponentMeta } from "@storybook/react";
import { createPageStory } from "../createPageStory";
const pageId = "webauthn-authenticate.ftl";
const { PageStory } = createPageStory({ pageId });
const meta: ComponentMeta<any> = {
title: `login/${pageId}`,
component: PageStory,
parameters: {
viewMode: "story",
previewTabs: {
"storybook/docs/panel": {
"hidden": true
}
}
}
};
export default meta;
export const Default = () => <PageStory />;

View File

@ -1,180 +0,0 @@
# Terms of Service
## Presentation / Features
[EC: continuation of today's meeting: this would deserve to differentiate the SSP Cloud from the Onyxia SSP Cloud instance]
The SSP Cloud is a service (hereinafter referred to as "the service") implemented by the National Institute for Statistics and Economic Studies (hereinafter referred to as "Insee").
The SSP Cloud is an implementation of free software [Onyxia] (https://github.com/InseeFrLab/onyxia) created and maintained by the innovation and technical instruction division of INSEE (information system management / innovation unit and information system strategy). The SSP Cloud is hosted by INSEE.
[EC: I will remove the "on open data", since the SSP Cloud can accommodate secure data under the appropriate conditions]
The SSP Cloud is a platform offering a "datalab" intended for _data science_ experiments on open data in which users can orchestrate services dedicated to the practice of _data science_ (development environments, databases, etc.). This service offering thus aims to familiarize users with new collaborative working methods using _open source_ statistical languages (R, python, Julia, etc.), _cloud computing_ type technologies, as well as to allow processing experiments. innovative statistics. The services offered are standard.
The SSP Cloud is aimed at officials of the official statistical system as well as teachers and students of the Group of National Schools of Economics and Statistics, allowing inter-service collaboration and cooperation with their ecosystem. Access can thus be granted on request and after decision of the governance bodies of the Cloud SSP to external collaborators and involved in the realization of experimental projects of the official statistical system. Projects involving non-open data are also subject to the decision of the governing bodies.
The SSP Cloud allows:
- the orchestration of _data science_ trainings
- access to _data science_ services
- secure data storage
- management of secrets, such as encryption keys
- access to a code management service
- orchestration of data processing flows
A user account is also used to connect to the service platform of the Inter-ministerial Mutualization Free Software community (<https://groupes.mim-libre.fr/>).
## Legal Notice
Functional administration of the Cloud SSP: Insee
This site is published by the National Institute for Statistics and Economic Studies (Insee).
INSEE
88 avenue Verdier
CS 70058
92541 Montrouge cedex
Director of publication: Mr. Jean-Luc Tavernier
Administrator: Frédéric Comte
Maintenance of the _open source_ Onyxia project: Insee
Hosting: Insee - Innovation and technical instruction division
## Terms of use of the Service
The SSP Cloud datalab can be accessed from any browser connected to
Internet. The use of a computer is recommended. Use of the datalab services is free.
The user community is accessible on:
- Tchap, salon [SSP Cloud] (https://www.tchap.gouv.fr/#/room/#SSPCloudXDpAw6v:agent.finances.tchap.gouv.fr)
- Rocket Chat at MIM Libre, [SSP Cloud] lounge (https://chat.mim-libre.fr/channel/sspcloud)
## Limits of use of the Service
Public data and data can be processed on the datalab
usual (working data without particular sensitivity). In the absence of specific authorization for a given experimental project, cannot be
"protected" or "sensitive" data processed on the datalab, with or without a
confidentiality intended to restrict distribution to a specific domain
(statistical, commercial, industrial secrecy, etc.).
[EC: seems too "weak" to me, refer to the opinion of the UAJC on this point: if an agent puts sensitive data on the datalab, under his responsibility, what is the responsibility of his employer? from INSEE? can be added "after he has taken a legal opinion on the character 'protected' or 'sensitive' and that he informed his hierarchy ??]
The "protected" or "sensitive" nature of the information stored or processed on the datalab
is subject to the discretion of the user under his own
responsibility.
## Roles, commitments and associated responsibilities
The service is made available by INSEE without other express guarantees or
tacit than those provided herein. The service is based on benchmark open source technologies. However, it is not guaranteed that it
is free from anomalies or errors. The service is therefore made available ** without
guaranteed availability and performance **. As such, INSEE cannot
be held responsible for loss and / or damage of any kind
be, who couldbe caused as a result of a malfunction or
unavailability of the service. Such situations will not give right to any
financial compensation.
Each user has a personal storage space. By default, all the information deposited in a user's storage space is accessible only to him. Each user has the possibility of making public files stored in their personal storage space. Each user is responsible for making their files available to the public.
[EC: take the opinion of the UAJC, I do not know if it is the user specifically who is responsible for the processing or the institution on which he depends]
Each user is responsible for processing all the experimental work he performs on the SSP Cloud.
He must, if necessary, declare the personal processing carried out using the SSP Cloud to the data protection officer of his structure and inform the members thereof. [not sure that it is only the DPD of his structure who must be aware, also the DPD Insee?]
[EC: in the case of a project involving several institutions, users must have previously established a data sharing / provision agreement.]
## Creating an account on the SSP Cloud
Access to the SSP Cloud requires prior registration and authentication.
## Experimental projects on sensitive data
** TODO **
Role of the project security manager
Enrollment of sensitive projects
Creation of collaborative spaces for sensitive projects
Creation and life cycle of spaces
## Processing of personal data
Data processing is based on the performance of the mission of providing a platform dedicated to experimentation and learning about data science for the benefit of the official statistical system.
The Service only collects the data strictly necessary for its implementation.
artwork.
The processing of personal data within the meaning of Articles 9 and 10 of
general data protection regulation (racial or ethnic origin,
political opinions, religious or philosophical beliefs, belonging
union, criminal convictions ...) is banned on the SSP Cloud.
[EC: same remark as above -> have the opinion of the Legal Unit]
Personal data processed as part of an experiment carried out by a user, when there is any, is the responsibility of the entity
administrative office from which the user originated. The
arrangements for their treatment must be communicated by
the user to the data protection officer of his entity
administrative unit.
Regarding the scope of the SSP Cloud service, the purpose of processing
concerns the management of the platform's accounts
(creation / conservation / deletion), operation of the platform (monitoring,
usage statistics) as well as the management of the services offered by the platform. Below is the list of
transverse personal data whose processing is under the
responsibility of INSEE.
** Suite to be managed with the DC POD **
> RL: @Fred, I put it a bit at random, I let you complete / amend
### Profile data
their first name, last name and email address (required);
freely:
- photo (see gitlab)
- ...
### Trace data
They are collected each time a user connects and, for example,
the use of a technical identifier, to trace connection operations and
modification of the objects of the service database.
They are used for technical support purposes. They can also do
subject to periodic review by the directors for control purposes and usage statistics.
### Cookie data
These cookies are only intended to allow the service to function and
to facilitate its use by users according to the constraints of each typology.
- Session cookie: mandatory, it identifies the session of
the user. The cookie is destroyed at the end of the session.
- Reauthentication cookie: optional, it allows you to re-authenticate
the user logged in for the duration of the cookie (one year maximum)
## Modification and evolution of the Service
INSEE reserves the right to develop, modify or suspend,
without notice, the Service for maintenance reasons or for any other
reason deemed necessary. The information is then communicated to users via Tchap.
The terms of these conditions of use may be modified or
completed at any time, without notice, depending on changes
made to the Service, changes in legislation or for any other reason
deemed necessary. These modifications and updates are binding on the user who
should therefore refer regularly to this section to verifythe
general conditions in force (accessible from the home page).
## Contact
For technical problems and / or
functionalities encountered on the platform, it is recommended, first of all
time to solicit communities of peers in collaborative spaces
provided for this purpose on Tchap and Rocket Chat-MIM Libre.
CNIL right of access for: <innovation@insee.fr>

View File

@ -1,180 +0,0 @@
# Conditions générales d'utilisation
## Présentation / Fonctionnalités
[EC: suite de la réunion d'aujourdhui : cela mériterait de différencier le SSP Cloud de l'instance d'Onyxia SSP Cloud]
Le SSP Cloud est un service (ci après désigné par "le service") mis en œuvre par l'Institut national de la statistique et des études économiques (ci-après dénommé "l'Insee").
Le SSP Cloud est une implémentation du logiciel libre [Onyxia](https://github.com/InseeFrLab/onyxia) créé et maintenu par la division innovation et instruction technique de l'Insee (direction du système d'information/unité innovation et stratégie du système d'information). Lhébergement du SSP Cloud est assuré par l'Insee.
[EC: j'enlèverai le "sur données ouvertes", puisque le SSP Cloud peut accueillir dans les donditions idoines des données sécurisées]
Le SSP Cloud est une plateforme proposant un "datalab" destiné aux expérimentations de _data science_ sur données ouvertes dans lequel les utilisateurs peuvent orchestrer des services dédiés à la pratique de la _data science_ (environnements de développement, bases de données...). Cette offre de services vise ainsi à familiariser les utilisateurs avec de nouvelles méthodes de travail collaboratif mobilisant des langages statistiques _open source_ (R, python, Julia...), des technologies de type _cloud computing_ ainsi qu'à permettre d'expérimenter des traitements statistiques innovants. Les services proposés sont standards.
Le SSP Cloud sadresse aux agents du système statistique public ainsi qu'aux enseignants et étudiants du Groupe des écoles nationales d'économie et de statistique, permettant une collaboration interservices et la coopération avec leur écosystème. Des accès peuvent ainsi être accordés sur demande et après décision des organes de gouvernance du SSP Cloud à des collaborateurs extérieurs et impliqués dans la réalisation de projets expérimentaux du système statistique public. Les projets mobilisant des données non ouvertes sont aussi soumis à la décision des organes de gouvernance.
Le SSP Cloud permet :
- l'orchestration de formations de _data science_
- l'accès à des services de _data science_
- le stockage sécurisé de données
- la gestion de secrets, tels que des clés de chiffrement
- l'accès à un service de gestion de code
- l'orchestration de flux de traitement de données
Un compte utilisateur permet également de se connecter à la plateforme de services de la communauté Mutualisation Inter-ministérielle Logiciels Libres (<https://groupes.mim-libre.fr/>).
## Mentions légales
Administration fonctionnelle du SSP Cloud : Insee
Ce site est édité par l'Institut national de la statistique et des études économiques (Insee).
Insee
88 avenue Verdier
CS 70058
92541 Montrouge cedex
Directeur de la publication : Monsieur Jean-Luc Tavernier
Administrateur : Frédéric Comte
Maintenance du projet _open source_ Onyxia : Insee
Hébergement : Insee - Division innovation et instruction technique
## Modalités dutilisation du Service
Le datalab SSP Cloud est accessible depuis nimporte quel navigateur connecté à
Internet. L'utilisation d'un ordinateur est recommandée. Lutilisation des services du datalab est gratuite.
La communauté d'utilisateurs est accessible sur :
- Tchap, salon [SSP Cloud](https://www.tchap.gouv.fr/#/room/#SSPCloudXDpAw6v:agent.finances.tchap.gouv.fr)
- Rocket Chat du MIM Libre, salon [SSP Cloud](https://chat.mim-libre.fr/channel/sspcloud)
## Limites dutilisation du Service
Peuvent être traitées sur le datalab les données publiques et données
usuelles (données de travail sans sensibilité particulière). En l'absence d'autorisation spécifique pour un projet d'expérimentation donné, ne peuvent être
traitées sur le datalab les données protégées ou sensibles, avec ou sans marque de
confidentialité destinée à restreindre la diffusion à un domaine spécifique
(secret statistique, commercial, industriel..).
[EC: me semble trop "faible", se référer à l'avis de l'UAJC sur ce point : si un agent met des données sensibles sur le datalab, sous sa responsabilité, quelle est la responsabilité de son employeur? de l'Insee ? peut être ajouter "après qu'il ait pris un avis juridique sur le caractère 'protégé' ou 'sensible' et qu'il en ait informé sa hiérarchie??]
Le caractère protégé ou sensible des informations stockées ou traitées sur le datalab
est soumis à lappréciation de lutilisateur sous sa propre
responsabilité.
## Les rôles, engagements et responsabilités associées
Le service est mis à disposition par l'Insee sans autres garanties expresses ou
tacites que celles qui sont prévues par les présentes. Le service sappuie sur des technologies open source de référence. Toutefois, il nest pas garanti quil
soit exempt danomalies ou erreurs. Le service est donc mis à disposition **sans
garantie sur sa disponibilité et ses performances**. A ce titre, l'Insee ne peut
être tenue responsable des pertes et/ou préjudices, de quelque nature quils
soient, qui pourraient être causés à la suite dun dysfonctionnement ou une
indisponibilité du service. De telles situations n'ouvriront droit à aucune
compensation financière.
Chaque utilisateur dispose d'un espace de stockage personnel. Par défaut, toutes les informations déposées dans un espace de stockage d'un utilisateur ne sont accessibles qu'à lui seul. Chaque utilisateur a la possibilité de rendre publics des fichiers stockés dans son espace de stockage personnel. Chaque utilisateur est responsable de la mise à disposition publique de ses fichiers.
[EC : prendre l'avis de l'UAJC, je ne sais pas si c'est l'utilisateur nommément qui est responsable du traitement ou bien l'institution dont il dépend]
Chaque utilisateur est responsable de traitement pour lensemble des travaux d'expérimentation qu'il réalise sur le SSP Cloud.
Il doit, le cas échant, déclarer les traitements à caractère personnel réalisés à l'aide du SSP Cloud au délégué à la protection des données de sa structure et en informer les membres. [pas sur que ce soit uniquement le DPD de sa structure qui doit être au courant, aussi le DPD Insee?]
[EC : dans le cas d'un projet faisant intervenir plusieurs institutions, les utilisateurs doivent avoir au préalable établi un conventionnement de partage/ mise à disposition des données.]
## La création de compte sur le SSP Cloud
L'accès au SSP Cloud nécessite une inscription préalable et une authentification.
## Les projets d'expérimentation sur données sensibles
**TODO**
Rôle du responsable de sécurité du projet
Enrôlement des projets sensibles
Création d'espaces collaboratifs pour les projets sensibles
Création et cycle de vie des espaces
## Traitement des données à caractère personnel
Le traitement des données se fonde sur lexécution de la mission que constitue la mise à disposition d'une plateforme dédiée à l'expérimentation et à l'apprentissage de la datascience au bénéfice du système statistique public.
Le Service ne collecte que les données strictement nécessaires à sa mise en
œuvre.
Le traitement de données à caractère personnel au sens des articles 9 et 10 du
règlement général sur la protection des données (origine raciale ou ethnique,
opinions politiques, convictions religieuses ou philosophiques, appartenance
syndicale, condamnations pénales...) est proscrit sur le SSP Cloud.
[EC: meme remarque que ci-dessus --> avoir l'avis de l'Unité juridique]
Les données à caractère personnel traitées dans le cadre d'une expérimentation réalisée par un utilisateur, quand il y en a, relèvent de la responsabilité de lentité
administrative dont est issu lutilisateur. Les
dispositions relatives à leur traitement doivent être communiquées par
l'utilisateur au délégué à la protection des données de son entité
administrative de rattachement.
Pour ce qui est du périmètre du service SSP Cloud, la finalité de traitement
concerne la gestion des comptes de la plateforme
(création/conservation/suppression), lexploitation de la plateforme (suivi,
statistiques dusages) ainsi que la gestion des services offerts par la plateforme. Ci-dessous la liste des
données à caractère personnel transverses dont le traitement est sous la
responsabilité de l'Insee.
**Suite à gérer avec le DC POD**
> RL : @Fred, je mets un peu au hasard, je te laisse compléter/amender
### Données relatives au profil
ses prénom, nom et adresse mail (obligatoire) ;
de façon libre :
- photo (cf. gitlab)
- ...
### Données de trace
Elles sont collectées à chaque connexion d'un utilisateur et permettent, par
lutilisation dun identifiant technique, de tracer les opérations de connexion et
de modification des objets de la base de données du service.
Elles servent à des fins de support technique. Elles peuvent également faire
l'objet d'une revue périodique de la part des administrateurs à des fins de contrôle et de statistiques d'usage.
### Les données de cookies
Ces cookies nont pour objet que de permettre le fonctionnement du service et
de faciliter son usage par les utilisateurs selon les contraintes chaque typologie.
- Cookie de session : obligatoire , il permet d'identifier la session de
l'utilisateur. Le cookie est détruit à la fin de la session.
- Cookie de réauthentification : optionnel, il permet de ré-authentifier
l'utilisateur connecté pendant la durée du cookie (un an maximum)
## Modification et évolution du Service
L'Insee se réserve la liberté de faire évoluer, de modifier ou de suspendre,
sans préavis, le Service pour des raisons de maintenance ou pour tout autre
motif jugé nécessaire. L'information est alors communiquée aux utilisateurs via Tchap.
Les termes des présentes conditions dutilisation peuvent être modifiés ou
complétés à tout moment, sans préavis, en fonction des modifications
apportées au Service, de lévolution de la législation ou pour tout autre motif
jugé nécessaire. Ces modifications et mises à jour simposent à lutilisateur qui
doit, en conséquence, se référer régulièrement à cette rubrique pour vérifier les
conditions générales en vigueur (accessible depuis la page daccueil).
## Contact
Pour les problèmes techniques et/ou
fonctionnels rencontrés sur la plateforme, il est conseillé, dans un premier
temps de solliciter les communautés de pairs dans les espaces collaboratifs
prévus à cet effet sur Tchap et Rocket Chat-MIM Libre.
Droit daccès CNIL pour : <innovation@insee.fr>

View File

@ -3,7 +3,7 @@ import { fromBuffer, Entry, ZipFile } from "yauzl";
import { it, describe, assert, afterAll } from "vitest";
import { Readable } from "stream";
import { tmpdir } from "os";
import { mkdtemp, cp, mkdir, rm, writeFile } from "fs/promises";
import { mkdtemp, cp, mkdir, rm } from "fs/promises";
import path from "path";
import { createReadStream } from "fs";
import walk from "keycloakify/bin/tools/walk";
@ -98,17 +98,12 @@ describe("jar", () => {
it("creates a jar from _real_ files without error", async () => {
const tmp = await mkdtemp(path.join(tmpdir(), "kc-jar-test-"));
tmpDirs.push(tmp);
const rootPath = path.join(tmp, "root");
const resourcesPath = path.join(tmp, "root", "src", "main", "resources");
const rootPath = path.join(tmp, "src");
const targetPath = path.join(tmp, "jar.jar");
await mkdir(rootPath);
await mkdir(resourcesPath, { recursive: true });
await writeFile(path.join(rootPath, "pom.xml"), "foo", "utf-8");
await cp(path.dirname(__dirname), resourcesPath, { recursive: true });
await cp(path.dirname(__dirname), rootPath, { recursive: true });
await jar({ ...coords, rootPath, targetPath });
@ -119,12 +114,11 @@ describe("jar", () => {
assert.isOk(entries.has("META-INF/MANIFEST.MF"));
assert.isOk(entries.has("META-INF/maven/someGroupId/someArtifactId/pom.properties"));
assert.isOk(entries.has("META-INF/maven/someGroupId/someArtifactId/pom.xml"));
for await (const fsPath of walk(resourcesPath)) {
for await (const fsPath of walk(rootPath)) {
if (!fsPath.endsWith(path.sep)) {
const rel = path.relative(resourcesPath, fsPath).replace(path.sep === "/" ? /\//g : /\\/g, "/");
assert.isOk(zipPaths.includes(rel), `missing '${rel}' (${rel}, '${zipPaths.join("', '")}')`);
const rel = path.relative(rootPath, fsPath).replace(path.sep === "/" ? /\//g : /\\/g, "/");
assert.isOk(zipPaths.includes(rel), `missing ${rel} (${rel}, ${zipPaths.join(", ")})`);
}
}
});

View File

@ -53,7 +53,8 @@ describe("Sample Project", () => {
const destDirPath = pathJoin(
readBuildOptions({
"processArgv": ["--silent"],
"isExternalAssetsCliParamProvided": false,
"isSilent": true,
"projectDirPath": process.cwd()
}).keycloakifyBuildDirPath,
"src",
@ -61,7 +62,7 @@ describe("Sample Project", () => {
"resources",
"theme"
);
await downloadBuiltinKeycloakTheme({ destDirPath, keycloakVersion: "11.0.3", "isSilent": false });
await downloadBuiltinKeycloakTheme({ destDirPath, keycloakVersion: "11.0.3", isSilent: false });
},
{ timeout: 90000 }
);
@ -79,7 +80,8 @@ describe("Sample Project", () => {
const destDirPath = pathJoin(
readBuildOptions({
"processArgv": ["--silent"],
"isExternalAssetsCliParamProvided": false,
"isSilent": true,
"projectDirPath": process.cwd()
}).keycloakifyBuildDirPath,
"src",
@ -87,7 +89,7 @@ describe("Sample Project", () => {
"resources",
"theme"
);
await downloadBuiltinKeycloakTheme({ destDirPath, "keycloakVersion": "11.0.3", "isSilent": false });
await downloadBuiltinKeycloakTheme({ destDirPath, keycloakVersion: "11.0.3", isSilent: false });
},
{ timeout: 90000 }
);

892
yarn.lock

File diff suppressed because it is too large Load Diff