Compare commits

..

105 Commits

Author SHA1 Message Date
569e933f02 Release candidate 2024-09-02 03:37:36 +02:00
46c40d713a Fix broken path 2024-09-02 03:37:16 +02:00
f3602219f3 Release candidate 2024-09-02 03:27:09 +02:00
c6b52acf2f #631 2024-09-02 03:26:39 +02:00
7260589136 Always generates the pages for legacy keycloak no matter what. 2024-08-30 19:27:56 +02:00
b2e9ddaa4f Bump version 2024-08-30 15:35:45 +02:00
4338b3ecb7 #627 2024-08-30 15:35:21 +02:00
0f81d9f146 Update readme 2024-08-29 01:22:16 +02:00
9980b10a83 Bump version 2024-08-28 17:09:21 +02:00
6bfd388827 Fix bug package.json not properly updated when initializing the single page account theme with webpack 2024-08-28 17:08:11 +02:00
8203ed687b Bump version 2024-08-28 14:43:40 +02:00
f394e06e4d Fix bug in type validation for webpack when initializing the account theme 2024-08-28 14:43:25 +02:00
8db35a81da Bump version 2024-08-27 17:41:04 +02:00
2e0ebfcf58 https://github.com/keycloakify/keycloakify-starter/issues/25 2024-08-27 17:40:48 +02:00
51d2ff85e0 Bump version 2024-08-26 18:20:32 +02:00
8b54426b89 Fix invalid dom nesting 2024-08-26 18:20:32 +02:00
fa346c5b1f Merge pull request #621 from keycloakify/all-contributors/add-liamlows
docs: add liamlows as a contributor for code, and doc
2024-08-26 04:27:06 +02:00
d87788980d docs: update .all-contributorsrc [skip ci] 2024-08-26 02:26:54 +00:00
1e4319498c docs: update README.md [skip ci] 2024-08-26 02:26:53 +00:00
48501407fc Release v10 🎉 2024-08-26 04:05:40 +02:00
01cbdee2ca Release candidate 2024-08-25 19:02:33 +02:00
b70c0af0a9 Add users to provided realm configuration if none exists 2024-08-25 19:02:00 +02:00
dcaee9cb7f Release candidate 2024-08-25 03:19:58 +02:00
1d8b6c7792 Fix logical error in multivalued attributes 2024-08-25 03:19:34 +02:00
c98dbe84c6 Add missing space before the * 2024-08-25 02:54:46 +02:00
1785916d32 download and extract actually just for downloading and extracting 2024-08-24 23:15:54 +02:00
c6cf564842 Release candidate 2024-08-23 19:01:56 +02:00
380b739017 Don't pin the patch version in the docker tag 2024-08-23 19:01:37 +02:00
c3f3c55303 Release candidate 2024-08-23 18:45:56 +02:00
2c01018529 #618 2024-08-23 18:36:40 +02:00
dd2edf3013 Merge pull request #616 from keycloakify/all-contributors/add-oliviergoulet5
docs: add oliviergoulet5 as a contributor for code
2024-08-22 00:56:15 +02:00
7f3cdf9fac Release candidate 2024-08-22 00:55:39 +02:00
f75a91fbc1 docs: update .all-contributorsrc [skip ci] 2024-08-21 22:55:00 +00:00
f151086bb1 docs: update README.md [skip ci] 2024-08-21 22:54:59 +00:00
7c833e6f10 Merge pull request #615 from oliviergoulet5/fix-array-operations
Fix array comparison and improve type check
2024-08-22 00:53:48 +02:00
885e8314e8 Fix array comparison and type check 2024-08-21 17:13:06 -04:00
3bdd955ab6 Release candidate 2024-08-19 02:11:31 +02:00
9499587bad Fix formating bug of Docker command being run 2024-08-19 02:10:59 +02:00
0879ddba7c Release candidate 2024-08-19 00:25:54 +02:00
106a1dd4c7 Support parsing of the KC_HTTP_RELATIVE_PATH option 2024-08-19 00:25:41 +02:00
5580248bcd Release candidate 2024-08-19 00:00:22 +02:00
c9c10b8fba Fix issue with the port in the start-keycloak command 2024-08-19 00:00:08 +02:00
ed254922e9 Relase candidate 2024-08-18 23:46:12 +02:00
4b7d1e2cec Fix bug in docker command 2024-08-18 23:45:58 +02:00
775ae57258 Release candidate 2024-08-18 21:10:37 +02:00
96e4cd79ee Enable to configure the port via the build options 2024-08-18 21:10:18 +02:00
bb70f7df4f Release candidate 2024-08-18 20:56:34 +02:00
602de2e407 Fix bug with spaces in docker run command 2024-08-18 20:56:25 +02:00
225ced989c Release candidate 2024-08-18 19:20:57 +02:00
ab53698f34 Merge pull request #612 from keycloakify/extensions
keycloak start command options support in config
2024-08-18 19:20:31 +02:00
02f2124126 keycloak start command options support in config 2024-08-18 19:19:35 +02:00
66623e3324 Release candidate 2024-08-16 08:48:21 +02:00
4cc886fd04 Update misleading note in the readme 2024-08-16 08:48:06 +02:00
a10b490245 Release candidate 2024-08-15 22:40:34 +02:00
b947b8a00d Display name and displayNameHtml are always provided 2024-08-15 22:40:08 +02:00
60fa240a4d #611 2024-08-15 22:38:45 +02:00
e05cd87b7c Release candidate 2024-08-14 18:32:21 +02:00
8e41c905ed Add the icons to the social provider in the story 2024-08-14 18:31:55 +02:00
e21f607ab0 Merge pull request #609 from keycloakify/all-contributors/add-madmadson
docs: add madmadson as a contributor for code
2024-08-14 16:36:59 +02:00
34af5abb82 docs: update .all-contributorsrc [skip ci] 2024-08-14 14:36:45 +00:00
fc1cdb5dc9 docs: update README.md [skip ci] 2024-08-14 14:36:44 +00:00
069a0cc980 Release candidate 2024-08-14 16:34:45 +02:00
78363727e1 Add correct fetch options to octokit 2024-08-14 16:34:22 +02:00
23b16746f6 Release candidate 2024-08-14 15:48:37 +02:00
6edf9c3d15 Fix div duplication 2024-08-14 15:48:16 +02:00
2e371d2078 Fix linking script for windows 2024-08-14 07:11:16 +02:00
b70b478e25 Pin cheerio to a given version 2024-08-13 14:58:46 +02:00
97ad132086 Update to latest typescript v4 release 2024-08-13 09:31:23 +02:00
2c5c54bf46 Don't use default import for cheerio (prepare for v1) 2024-08-13 09:25:06 +02:00
c0ca078b43 Release candidate 2024-08-13 00:20:54 +02:00
53e94d04f6 Improve message related to pnpm dlx 2024-08-13 00:17:26 +02:00
dd198f9f06 Tell pepole they can explicitely provide the keycloak version 2024-08-13 00:17:26 +02:00
43f455f4d0 Provide the proxy options to oktokit 2024-08-13 00:17:26 +02:00
d9132ea5a5 Merge pull request #603 from keycloakify/debug_fetch_proxy
Debug fetch proxy
2024-08-07 19:28:04 +02:00
d5c7e2547b Release candidate 2024-08-07 19:01:15 +02:00
13b87de06c Remove debug log 2024-08-07 19:00:57 +02:00
83bdbb7a7e Release candidate 2024-08-07 16:07:25 +02:00
89320b8d51 Fix get proxy option 2024-08-07 16:07:07 +02:00
5fa9c3879c Release candidate 2024-08-07 11:48:02 +02:00
c0cd76d40e Debug log for proxy config 2024-08-07 11:46:05 +02:00
01f60f8013 Release candidate 2024-08-07 07:51:07 +02:00
91ad0712af Make defaultuser english in keycloak 25 2024-08-07 07:33:48 +02:00
2cb1b36725 Release candidate 2024-08-07 06:22:22 +02:00
67ce66765f Enable delete account in default Keycloak realm configuration 2024-08-07 06:21:59 +02:00
c8cc453942 Release candidate 2024-08-06 06:42:02 +02:00
3f835f152f #602 2024-08-06 06:41:25 +02:00
35e8a853e0 Release candidate 2024-08-02 14:09:29 +02:00
d084a4bf4a Fix bug spaces in path keycloak-start 2024-08-02 14:09:09 +02:00
2a6b79e097 Release candidate 2024-07-31 18:46:10 +02:00
5d786c922f Enable the errors to be displayed immediately and not after focus is lost 2024-07-31 18:45:48 +02:00
26bd5dd534 Release candidate 2024-07-31 11:57:38 +02:00
b4df0ce52c Set the default user locale to english 2024-07-31 11:57:13 +02:00
386a8d7cd7 Rework the storybook 2024-07-29 05:12:31 +02:00
5221fb3479 Prevent reload loop in storybook 2024-07-29 02:48:57 +02:00
2871f63f25 Mention account Single Page in the storybook 2024-07-29 00:29:36 +02:00
4c282d0559 Release candidate 2024-07-28 20:01:27 +02:00
4ac14dc074 Prevent exposing too much information in the kcContext.realm of the single page account UI 2024-07-28 20:01:11 +02:00
fcdbb04ea6 Do not make select theme type when there's only one option 2024-07-28 19:37:15 +02:00
14f283cf49 Do not enable to add story when single page account theme 2024-07-28 19:33:27 +02:00
efc459663a Adapt eject-page for Single-Page account ui 2024-07-28 19:24:00 +02:00
d459aaf943 Add hint on how to enable 2024-07-28 18:32:22 +02:00
921c7d5441 Restore the CI setup for main 2024-07-27 18:03:15 +02:00
7d7e648968 Merge pull request #538 from keycloakify/keycloak_24
Keycloakify v10 (Keycloak v24 & 25 support and much more)
2024-07-27 17:56:06 +02:00
1e5eae69e9 Update README.md 2024-07-27 00:52:57 +02:00
1638577d98 Update sponsors section 2024-07-20 12:37:05 +02:00
70 changed files with 1946 additions and 1782 deletions

View File

@ -231,6 +231,34 @@
"contributions": [
"code"
]
},
{
"login": "madmadson",
"name": "Tobias Matt",
"avatar_url": "https://avatars.githubusercontent.com/u/798831?v=4",
"profile": "https://github.com/madmadson",
"contributions": [
"code"
]
},
{
"login": "oliviergoulet5",
"name": "Olivier Goulet",
"avatar_url": "https://avatars.githubusercontent.com/u/17685861?v=4",
"profile": "https://github.com/oliviergoulet5",
"contributions": [
"code"
]
},
{
"login": "liamlows",
"name": "Liam Lowsley-Williams",
"avatar_url": "https://avatars.githubusercontent.com/u/1365914?v=4",
"profile": "https://github.com/liamlows",
"contributions": [
"code",
"doc"
]
}
],
"contributorsPerLine": 7,

View File

@ -37,7 +37,7 @@ jobs:
storybook:
runs-on: ubuntu-latest
#if: github.event_name == 'push'
if: github.event_name == 'push'
needs: test
steps:
- uses: actions/checkout@v4

View File

@ -1,70 +0,0 @@
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 }) {
const isStorybookUiDark = useDarkMode();
const theme = isStorybookUiDark ? darkTheme : lightTheme;
const backgroundColor = theme.appBg;
return (
<>
<style>{`
body {
padding: 0 !important;
background-color: ${backgroundColor};
}
.docs-story {
background-color: ${backgroundColor};
}
[id^=story--] .container {
border: 1px dashed #e8e8e8;
}
.docblock-argstable-head th:nth-child(3), .docblock-argstable-body tr > td:nth-child(3) {
visibility: collapse;
}
.docblock-argstable-head th:nth-child(3), .docblock-argstable-body tr > td:nth-child(2) p {
font-size: 13px;
}
`}</style>
<BaseContainer
context={{
...context,
"storyById": id => {
const storyContext = context.storyById(id);
return {
...storyContext,
"parameters": {
...storyContext?.parameters,
"docs": {
...storyContext?.parameters?.docs,
"theme": isStorybookUiDark ? darkTheme : lightTheme
}
}
};
}
}}
>
{children}
</BaseContainer>
</>
);
}
export function CanvasContainer({ children }) {
return (
<>
{children}
</>
);
}

View File

@ -1,35 +0,0 @@
import { create } from "@storybook/theming";
const brandImage = "logo.png";
const brandTitle = "Keycloakify";
const brandUrl = "https://github.com/keycloakify/keycloakify";
const fontBase = '"Work Sans", sans-serif';
const fontCode = "monospace";
export const darkTheme = create({
"base": "dark",
"appBg": "#1E1E1E",
"appContentBg": "#161616",
"barBg": "#161616",
"colorSecondary": "#8585F6",
"textColor": "#FFFFFF",
brandImage,
brandTitle,
brandUrl,
fontBase,
fontCode
});
export const lightTheme = create({
"base": "light",
"appBg": "#F6F6F6",
"appContentBg": "#FFFFFF",
"barBg": "#FFFFFF",
"colorSecondary": "#000091",
"textColor": "#212121",
brandImage,
brandTitle,
brandUrl,
fontBase,
fontCode
});

33
.storybook/customTheme.ts Normal file
View File

@ -0,0 +1,33 @@
const brandImage = "logo.png";
const brandTitle = "Keycloakify";
const brandUrl = "https://github.com/keycloakify/keycloakify";
const fontBase = '"Work Sans", sans-serif';
const fontCode = "monospace";
export const darkTheme = {
base: "dark",
appBg: "#1E1E1E",
appContentBg: "#161616",
barBg: "#161616",
colorSecondary: "#8585F6",
textColor: "#FFFFFF",
brandImage,
brandTitle,
brandUrl,
fontBase,
fontCode
};
export const lightTheme: typeof darkTheme = {
base: "light",
appBg: "#F6F6F6",
appContentBg: "#FFFFFF",
barBg: "#FFFFFF",
colorSecondary: "#000091",
textColor: "#212121",
brandImage,
brandTitle,
brandUrl,
fontBase,
fontCode
};

View File

@ -1,15 +1,13 @@
module.exports = {
"stories": [
"../stories/**/*.stories.@(ts|tsx|mdx)"
stories: [
"../stories/**/*.stories.tsx"
],
"addons": [
"@storybook/addon-links",
"@storybook/addon-essentials",
addons: [
"storybook-dark-mode",
"@storybook/addon-a11y"
],
"core": {
"builder": "webpack5"
core: {
builder: "webpack5"
},
"staticDirs": ["./static"]
staticDirs: ["./static"]
};

View File

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

View File

@ -1,3 +1,9 @@
<link rel="preload" href="/fonts/WorkSans/worksans-bold-webfont.woff2" as="font" crossorigin="anonymous">
<link rel="preload" href="/fonts/WorkSans/worksans-medium-webfont.woff2" as="font" crossorigin="anonymous">
<link rel="preload" href="/fonts/WorkSans/worksans-regular-webfont.woff2" as="font" crossorigin="anonymous">
<link rel="preload" href="/fonts/WorkSans/worksans-semibold-webfont.woff2" as="font" crossorigin="anonymous">
<link rel="stylesheet" type="text/css" href="/fonts/WorkSans/font.css">
<style>
body.sb-show-main.sb-main-padded {
padding: 0;

View File

@ -1,116 +1,105 @@
import { darkTheme, lightTheme } from "./customTheme";
import { DocsContainer, CanvasContainer } from "./Containers";
import { create as createTheme } from "@storybook/theming";
export const parameters = {
"actions": { "argTypesRegex": "^on[A-Z].*" },
"controls": {
"matchers": {
"color": /(background|color)$/i,
"date": /Date$/,
actions: { argTypesRegex: "^on[A-Z].*" },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/,
},
},
"backgrounds": { "disable": true },
"darkMode": {
"light": lightTheme,
"dark": darkTheme,
backgrounds: { disable: true },
darkMode: {
light: createTheme(lightTheme),
dark: createTheme(darkTheme),
},
"docs": {
"container": DocsContainer
controls: {
disable: true,
},
"controls": {
"disable": true,
actions: {
disable: true
},
"actions": {
"disable": true
},
"viewport": {
"viewports": {
viewport: {
viewports: {
"1440p": {
"name": "1440p",
"styles": {
"width": "2560px",
"height": "1440px",
name: "1440p",
styles: {
width: "2560px",
height: "1440px",
},
},
"fullHD": {
"name": "Full HD",
"styles": {
"width": "1920px",
"height": "1080px",
fullHD: {
name: "Full HD",
styles: {
width: "1920px",
height: "1080px",
},
},
"macBookProBig": {
"name": "MacBook Pro Big",
"styles": {
"width": "1024px",
"height": "640px",
macBookProBig: {
name: "MacBook Pro Big",
styles: {
width: "1024px",
height: "640px",
},
},
"macBookProMedium": {
"name": "MacBook Pro Medium",
"styles": {
"width": "1440px",
"height": "900px",
macBookProMedium: {
name: "MacBook Pro Medium",
styles: {
width: "1440px",
height: "900px",
},
},
"macBookProSmall": {
"name": "MacBook Pro Small",
"styles": {
"width": "1680px",
"height": "1050px",
macBookProSmall: {
name: "MacBook Pro Small",
styles: {
width: "1680px",
height: "1050px",
},
},
"pcAgent": {
"name": "PC Agent",
"styles": {
"width": "960px",
"height": "540px",
pcAgent: {
name: "PC Agent",
styles: {
width: "960px",
height: "540px",
},
},
"iphone12Pro": {
"name": "Iphone 12 pro",
"styles": {
"width": "390px",
"height": "844px",
iphone12Pro: {
name: "Iphone 12 pro",
styles: {
width: "390px",
height: "844px",
},
},
"iphone5se": {
"name": "Iphone 5/SE",
"styles": {
"width": "320px",
"height": "568px",
iphone5se: {
name: "Iphone 5/SE",
styles: {
width: "320px",
height: "568px",
},
},
"ipadPro": {
"name": "Ipad pro",
"styles": {
"width": "1240px",
"height": "1366px",
ipadPro: {
name: "Ipad pro",
styles: {
width: "1240px",
height: "1366px",
},
},
"Galaxy s9+": {
"name": "Galaxy S9+",
"styles": {
"width": "320px",
"height": "658px",
name: "Galaxy S9+",
styles: {
width: "320px",
height: "658px",
},
}
},
},
"options": {
"storySort": (a, b) =>
options: {
storySort: (a, b) =>
getHardCodedWeight(b[1].kind) - getHardCodedWeight(a[1].kind),
},
};
export const decorators = [
(Story) => (
<CanvasContainer>
<Story />
</CanvasContainer>
),
];
const { getHardCodedWeight } = (() => {
const orderedPagesPrefix = [

273
README.md
View File

@ -2,7 +2,7 @@
<img src="https://user-images.githubusercontent.com/6702424/109387840-eba11f80-7903-11eb-9050-db1dad883f78.png">
</p>
<p align="center">
<i>🔏 Create Keycloak themes using React 🔏</i>
<i>🔏 Keycloak Theming for the Modern Web 🔏</i>
<br>
<br>
<a href="https://github.com/garronej/keycloakify/actions">
@ -38,22 +38,36 @@
<i>This build tool generates a Keycloak theme <a href="https://www.keycloakify.dev">Learn more</a></i>
<br/>
<br/>
<img width="400" src="https://github.com/keycloakify/keycloakify/assets/6702424/e66d105c-c06f-47d1-8a31-a6ab09da4e80">
<img width="400" src="https://github.com/user-attachments/assets/6bf3bef9-00b0-4460-97b9-0d2da8500798">
</p>
Keycloakify is fully compatible with Keycloak 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, [~~22~~](https://github.com/keycloakify/keycloakify/issues/389#issuecomment-1822509763), 23, 24, 25...[and beyond](https://github.com/keycloakify/keycloakify/discussions/346#discussioncomment-5889791)
Keycloakify is fully compatible with Keycloak from version 11 to 25...[and beyond](https://github.com/keycloakify/keycloakify/discussions/346#discussioncomment-5889791)
> NOTE: Keycloakify 10 is still in realase-candidate state. [Follow progress](https://github.com/keycloakify/keycloakify/pull/538).
## Sponsors
## Sponsor
Friends for the project, we trust and recommend their services.
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.
Their dedicated support helps us continue the development and maintenance of this project.
<br/>
[Cloud IAM](https://cloud-iam.com/?mtm_campaign=keycloakify-deal&mtm_source=keycloakify-github) provides the following services:
<div align="center">
- Simplify and secure your Keycloak Identity and Access Management. Keycloak as a Service.
- Custom theme building for your brand using Keycloakify.
![Logo Dark](https://github.com/user-attachments/assets/088f6631-b7ef-42ad-812b-df4870dc16ae#gh-dark-mode-only)
</div>
<div align="center">
![Logo Light](https://github.com/user-attachments/assets/53fb16f8-02ef-4523-9c36-b42d6e59837e#gh-light-mode-only)
</div>
<br/>
<p align="center">
<a href="https://www.zone2.tech/services/keycloak-consulting">
<i><strong>Keycloak Consulting Services</strong> - Your partner in Keycloak deployment, configuration, and extension development for optimized identity management solutions.</i>
</a>
</p>
<div align="center">
@ -68,13 +82,11 @@ Their dedicated support helps us continue the development and maintenance of thi
</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>
<br/>
<i>5% of your annual subscription will be donated to us, and you'll get 5% off too.</i>
<a href="https://cloud-iam.com/?mtm_campaign=keycloakify-deal&mtm_source=keycloakify-github"><strong>Managed Keycloak Provider</strong> - With Cloud-IAM powering your Keycloak clusters, you can sleep easy knowing you've got the software and the experts you need for operational excellence. </a>
<br/>
Use code <code>keycloakify5</code> at checkout for a 5% discount.
</p>
Thank you, [Cloud-IAM](https://cloud-iam.com/?mtm_campaign=keycloakify-deal&mtm_source=keycloakify-github), for your support!
## Contributors ✨
Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)):
@ -116,7 +128,9 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
<td align="center" valign="top" width="14.28%"><a href="https://m-siemens.de/"><img src="https://avatars.githubusercontent.com/u/1873922?v=4?s=100" width="100px;" alt="Markus Siemens"/><br /><sub><b>Markus Siemens</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=msiemens" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/law108000"><img src="https://avatars.githubusercontent.com/u/8112024?v=4?s=100" width="100px;" alt="Rlok"/><br /><sub><b>Rlok</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=law108000" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Moulyy"><img src="https://avatars.githubusercontent.com/u/115405804?v=4?s=100" width="100px;" alt="Moulyy"/><br /><sub><b>Moulyy</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=Moulyy" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/giorgoslytos"><img src="https://avatars.githubusercontent.com/u/50946162?v=4?s=100" width="100px;" alt="giorgoslytos"/><br /><sub><b>giorgoslytos</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=giorgoslytos" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/madmadson"><img src="https://avatars.githubusercontent.com/u/798831?v=4?s=100" width="100px;" alt="Tobias Matt"/><br /><sub><b>Tobias Matt</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=madmadson" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/oliviergoulet5"><img src="https://avatars.githubusercontent.com/u/17685861?v=4?s=100" width="100px;" alt="Olivier Goulet"/><br /><sub><b>Olivier Goulet</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=oliviergoulet5" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/liamlows"><img src="https://avatars.githubusercontent.com/u/1365914?v=4?s=100" width="100px;" alt="Liam Lowsley-Williams"/><br /><sub><b>Liam Lowsley-Williams</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=liamlows" title="Code">💻</a> <a href="https://github.com/keycloakify/keycloakify/commits?author=liamlows" title="Documentation">📖</a></td>
</tr>
</tbody>
</table>
@ -125,230 +139,3 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
<!-- prettier-ignore-end -->
<!-- ALL-CONTRIBUTORS-LIST:END -->
# Changelog highlights
## 9.5
- Post build hook: You can now apply custom transformation to your theme files. [Learn more](https://docs.keycloakify.dev/build-options#postbuild-hook).
- You can now specify your option in the Keycloakify's Vite plugin instead in the package.json. [See example](https://docs.keycloakify.dev/build-options#themename).
## 9.4
**Vite Support! 🎉**
- [The starter is now a Vite project](https://github.com/keycloakify/keycloakify-starter).
The Webpack based starter is accessible [here](https://github.com/keycloakify/keycloakify-starter-cra).
- CRA (Webpack) remains supported for the forseable future.
- If you have a CRA Keycloakify theme that you wish to migrate to Vite checkout [this migration guide](https://docs.keycloakify.dev/migration-guides/cra-greater-than-vite).
## 9.0
Bring back support for account themes in Keycloak v23 and up! [See issue](https://github.com/keycloakify/keycloakify/issues/389).
### Breaking changes
Very few. Check them out [here](https://docs.keycloakify.dev/migration-guides/v8-greater-than-v9).
## 8.0
- Much smaller .jar size. 70.2 MB -> 7.8 MB.
Keycloakify now detects which of the static resources from the default theme are actually used by your theme and only include those in the .jar.
- Build time: The first build is slowed but the subsequent build are faster. [Update your CI so that the cache is persisted across CI build](https://github.com/keycloakify/keycloakify-starter/commit/bc378d5afb67e796f520afbc348185f3e319d9d0).
### Breaking changes
There are very few breaking changes in this major version. [Check them out](https://docs.keycloakify.dev/migration-guides/v7-greater-than-v8).
## 7.15
- The i18n messages you defines in your theme are now also maid available to Keycloak.
In practice this mean that you can now customize the `kcContext.message.summary` that
display a general alert and the values returned by `kcContext.messagesPerField.get()` that
are used to display specific error on some field of the form.
[See video](https://youtu.be/D6tZcemReTI)
## 7.14
- Deprecate the `extraPages` build option. Keycloakify is now able to analyze your code to detect extra pages.
## 7.13
- Deprecate `customUserAttribute`, Keycloakify now analyze your code to predict field name usage. [See doc](https://docs.keycloakify.dev/build-options#customuserattributes).
It's now mandatory to [adopt the new directory structure](https://docs.keycloakify.dev/migration-guides/v6-greater-than-v7).
## 7.12
- You can now pack multiple themes variant in a single `.jar` bundle. In vanilla Keycloak themes you have the ability to extend a base theme.
There is now an idiomatic way of achieving the same result. [Learn more](https://docs.keycloakify.dev/build-options#keycloakify.themeVariantNames).
## 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
- Better storybook support, see [the starter project](https://github.com/keycloakify/keycloakify-starter).
## 7.0 🍾
- Account theme support 🚀
- It's much easier to customize pages at the CSS level, you can now see in the browser dev tool the customizable classes.
- New interactive CLI tool `npx eject-keycloak-page`, that enables to select the page you want to customize at the component level.
- There is [a Storybook](https://storybook.keycloakify.dev)
- [Remember me is fixed](https://github.com/keycloakify/keycloakify/pull/272)
## 6.13
- Build work behind corporate proxies, [see issue](https://github.com/keycloakify/keycloakify/issues/257).
## 6.12
Massive improvement in the developer experience:
- There is now only one starter repo: https://github.com/codegouvfr/keycloakify-starter
- A lot of comments have been added in the code of the starter to make it easier to get started.
- The doc has been updated: https://docs.keycloakify.dev
- A lot of improvements in the type system.
## 6.11.4
- You no longer need to have Maven installed to build the theme. Thanks to @lordvlad, [see PR](https://github.com/keycloakify/keycloakify/pull/239).
- Feature new build options: [`bundler`](https://docs.keycloakify.dev/build-options#keycloakify.bundler), [`groupId`](https://docs.keycloakify.dev/build-options#keycloakify.groupid), [`artifactId`](https://docs.keycloakify.dev/build-options#keycloakify.artifactid), [`version`](https://docs.keycloakify.dev/build-options#version).
Theses options can be user to customize the output name of the .jar. You can use environnement variables to overrides the values read in the package.json. Thanks to @lordvlad.
## 6.10.0
- Widows compat (thanks to @lordvlad, [see PR](https://github.com/keycloakify/keycloakify/pull/226)). WSL is no longer required 🎉
## 6.8.4
- `@emotion/react` is no longer a peer dependency of Keycloakify.
## 6.8.0
- It is now possible to pass a custom `<Template />` component as a prop to `<KcApp />` and every
individual page (`<Login />`, `<RegisterUserProfile />`, ...) it enables to customize only the header and footer for
example without having to switch to a full-component level customization. [See issue](https://github.com/keycloakify/keycloakify/issues/191).
## 6.7.0
- Add support for `webauthn-authenticate.ftl` thanks to [@mstrodl](https://github.com/Mstrodl)'s hacktoberfest [PR](https://github.com/keycloakify/keycloakify/pull/185).
## 6.6.0
- Add support for `login-password.ftl` thanks to [@mstrodl](https://github.com/Mstrodl)'s hacktoberfest [PR](https://github.com/keycloakify/keycloakify/pull/184).
## 6.5.0
- Add support for `login-username.ftl` thanks to [@mstrodl](https://github.com/Mstrodl)'s hacktoberfest [PR](https://github.com/keycloakify/keycloakify/pull/183).
## 6.4.0
- You can now optionally pass a `doFetchDefaultThemeResources: boolean` prop to every page component and the default `<KcApp />`
This enables you to prevent the default CSS and JS that comes with the builtin Keycloak theme to be downloaded.
You'll get [a black slate](https://user-images.githubusercontent.com/6702424/192619083-4baa5df4-4a21-4ec7-8e28-d200d1208299.png).
## 6.0.0
- Bundle size drastically reduced, locals and component dynamically loaded.
- First print much quicker, use of React.lazy() everywhere.
- Real i18n API.
- Actual documentation for build options.
Checkout [the migration guide](https://docs.keycloakify.dev/v5-to-v6)
## 5.8.0
- [React.lazy()](https://reactjs.org/docs/code-splitting.html#reactlazy) support 🎉. [#141](https://github.com/keycloakify/keycloakify/issues/141)
## 5.7.0
- Feat `logout-confirm.ftl`. [PR](https://github.com/keycloakify/keycloakify/pull/120)
## 5.6.4
Fix `login-verify-email.ftl` page. [Before](https://user-images.githubusercontent.com/6702424/177436014-0bad22c4-5bfb-45bb-8fc9-dad65143cd0c.png) - [After](https://user-images.githubusercontent.com/6702424/177435797-ec5d7db3-84cf-49cb-8efc-3427a81f744e.png)
## 5.6.0
Add support for `login-config-totp.ftl` page [#127](https://github.com/keycloakify/keycloakify/pull/127).
## 5.3.0
Rename `keycloak_theme_email` to `keycloak_email`.
If you already had a `keycloak_theme_email` you should rename it `keycloak_email`.
## 5.0.0
[Migration guide](https://github.com/garronej/keycloakify-demo-app/blob/a5b6a50f24bc25e082931f5ad9ebf47492acd12a/src/index.tsx#L46-L63)
New i18n system.
Import of terms and services have changed. [See example](https://github.com/garronej/keycloakify-demo-app/blob/a5b6a50f24bc25e082931f5ad9ebf47492acd12a/src/index.tsx#L46-L63).
## 4.10.0
Add `login-idp-link-email.ftl` page [See PR](https://github.com/keycloakify/keycloakify/pull/92).
## 4.8.0
[Email template customization.](#email-template-customization)
## 4.7.4
**M1 Mac** support (for testing locally with a dockerized Keycloak).
## 4.7.2
> WARNING: This is broken.
> Testing with local Keycloak container working with M1 Mac. Thanks to [@eduardosanzb](https://github.com/keycloakify/keycloakify/issues/43#issuecomment-975699658).
> Be aware: When running M1s you are testing with Keycloak v15 else the local container spun will be a Keycloak v16.1.0.
## 4.7.0
Register with user profile enabled: Out of the box `options` validator support.
[Example](https://user-images.githubusercontent.com/6702424/158911163-81e6bbe8-feb0-4dc8-abff-de199d7a678e.mov)
## 4.6.0
`tss-react` and `powerhooks` are no longer peer dependencies of `keycloakify`.
After updating Keycloakify you can remove `tss-react` and `powerhooks` from your dependencies if you don't use them explicitly.
## 4.5.3
There is a new recommended way to setup highly customized theme. See [here](https://github.com/garronej/keycloakify-demo-app/blob/look_and_feel/src/KcApp/KcApp.tsx).
Unlike with [the previous recommended method](https://github.com/garronej/keycloakify-demo-app/blob/a51660578bea15fb3e506b8a2b78e1056c6d68bb/src/KcApp/KcApp.tsx),
with this new method your theme wont break on minor Keycloakify update.
## 4.3.0
Feature [`login-update-password.ftl`](https://user-images.githubusercontent.com/6702424/147517600-6191cf72-93dd-437b-a35c-47180142063e.png).
Every time a page is added it's a breaking change for non CSS-only theme.
Change [this](https://github.com/garronej/keycloakify-demo-app/blob/df664c13c77ce3c53ac7df0622d94d04e76d3f9f/src/KcApp/KcApp.tsx#L17) and [this](https://github.com/garronej/keycloakify-demo-app/blob/df664c13c77ce3c53ac7df0622d94d04e76d3f9f/src/KcApp/KcApp.tsx#L37) to update.
## 4
- Out of the box [frontend form validation](#user-profile-and-frontend-form-validation) 🥳
- Improvements (and breaking changes in `import { useKcMessage } from "keycloakify"`.
## 3
No breaking changes except that `@emotion/react`, [`tss-react`](https://www.npmjs.com/package/tss-react) and [`powerhooks`](https://www.npmjs.com/package/powerhooks) are now `peerDependencies` instead of being just dependencies.
It's important to avoid problem when using `keycloakify` alongside [`mui`](https://mui.com) and
[when passing params from the app to the login page](https://github.com/keycloakify/keycloakify#implement-context-persistence-optional).
## 2.5
- Feature [Use advanced message](https://github.com/keycloakify/keycloakify/blob/59f106bf9e210b63b190826da2bf5f75fc8b7644/src/lib/i18n/useKcMessage.tsx#L53-L66)
and [`messagesPerFields`](https://github.com/keycloakify/keycloakify/blob/59f106bf9e210b63b190826da2bf5f75fc8b7644/src/lib/getKcContext/KcContextBase.ts#L70-L75) (implementation [here](https://github.com/keycloakify/keycloakify/blob/59f106bf9e210b63b190826da2bf5f75fc8b7644/src/bin/build-keycloak-theme/generateFtl/common.ftl#L130-L189))
- Test container now uses Keycloak version `15.0.2`.
## 2
- It's now possible to implement custom `.ftl` pages.
- Support for Keycloak plugins that introduce non standard ftl values.
(Like for example [this plugin](https://github.com/micedre/keycloak-mail-whitelisting) that define `authorizedMailDomains` in `register.ftl`).

View File

@ -1,6 +1,6 @@
{
"name": "keycloakify",
"version": "10.0.0-rc.121",
"version": "10.1.0-rc.1",
"description": "Create Keycloak themes using React",
"repository": {
"type": "git",
@ -72,14 +72,10 @@
"@emotion/react": "^11.11.4",
"@octokit/rest": "^20.1.1",
"@storybook/addon-a11y": "^6.5.16",
"@storybook/addon-actions": "^6.5.13",
"@storybook/addon-essentials": "^6.5.13",
"@storybook/addon-interactions": "^6.5.13",
"@storybook/addon-links": "^6.5.13",
"@storybook/builder-webpack5": "^6.5.13",
"@storybook/manager-webpack5": "^6.5.13",
"@storybook/react": "^6.5.13",
"@storybook/testing-library": "^0.0.13",
"eslint-plugin-storybook": "^0.6.7",
"@types/babel__generator": "^7.6.4",
"@types/make-fetch-happen": "^10.0.1",
"@types/minimist": "^1.2.2",
@ -89,10 +85,9 @@
"@types/yauzl": "^2.10.3",
"@vercel/ncc": "^0.38.1",
"chalk": "^4.1.2",
"cheerio": "^1.0.0-rc.12",
"cheerio": "1.0.0-rc.12",
"chokidar-cli": "^3.0.0",
"cli-select": "^1.1.2",
"eslint-plugin-storybook": "^0.6.7",
"husky": "^4.3.8",
"lint-staged": "^11.0.0",
"magic-string": "^0.30.7",
@ -108,7 +103,7 @@
"termost": "^v0.12.1",
"tsc-alias": "^1.8.10",
"tss-react": "^4.9.10",
"typescript": "^4.9.1-beta",
"typescript": "^4.9.4",
"vite": "^5.2.11",
"vitest": "^1.6.0",
"yauzl": "^2.10.0",

View File

@ -1,7 +1,7 @@
import { join as pathJoin } from "path";
import { copyKeycloakResourcesToPublic } from "../src/bin/shared/copyKeycloakResourcesToPublic";
import { getProxyFetchOptions } from "../src/bin/tools/fetchProxyOptions";
import { LOGIN_THEME_RESOURCES_FROMkEYCLOAK_VERSION_DEFAULT } from "../src/bin/shared/constants";
import { LOGIN_THEME_RESOURCES_FROM_KEYCLOAK_VERSION_DEFAULT } from "../src/bin/shared/constants";
export async function copyKeycloakResourcesToStorybookStaticDir() {
await copyKeycloakResourcesToPublic({
@ -11,7 +11,7 @@ export async function copyKeycloakResourcesToStorybookStaticDir() {
npmConfigGetCwd: pathJoin(__dirname, "..")
}),
loginThemeResourcesFromKeycloakVersion:
LOGIN_THEME_RESOURCES_FROMkEYCLOAK_VERSION_DEFAULT,
LOGIN_THEME_RESOURCES_FROM_KEYCLOAK_VERSION_DEFAULT,
publicDirPath: pathJoin(__dirname, "..", ".storybook", "static")
}
});

View File

@ -13,6 +13,10 @@ import { downloadKeycloakDefaultTheme } from "../src/bin/shared/downloadKeycloak
import { getThisCodebaseRootDirPath } from "../src/bin/tools/getThisCodebaseRootDirPath";
import { deepAssign } from "../src/tools/deepAssign";
import { getProxyFetchOptions } from "../src/bin/tools/fetchProxyOptions";
import {
THEME_TYPES,
LAST_KEYCLOAK_VERSION_WITH_ACCOUNT_V1
} from "../src/bin/shared/constants";
// NOTE: To run without argument when we want to generate src/i18n/generated_kcMessages files,
// update the version array for generating for newer version.
@ -21,68 +25,77 @@ import { getProxyFetchOptions } from "../src/bin/tools/fetchProxyOptions";
const propertiesParser = require("properties-parser");
async function main() {
const keycloakVersion = "24.0.4";
const thisCodebaseRootDirPath = getThisCodebaseRootDirPath();
const { defaultThemeDirPath } = await downloadKeycloakDefaultTheme({
keycloakVersion,
buildContext: {
cacheDirPath: pathJoin(
thisCodebaseRootDirPath,
"node_modules",
".cache",
"keycloakify"
),
fetchOptions: getProxyFetchOptions({
npmConfigGetCwd: thisCodebaseRootDirPath
})
}
});
type Dictionary = { [idiomId: string]: string };
const record: { [typeOfPage: string]: { [language: string]: Dictionary } } = {};
const record: { [themeType: string]: { [language: string]: Dictionary } } = {};
{
const baseThemeDirPath = pathJoin(defaultThemeDirPath, "base");
const re = new RegExp(
`^([^\\${pathSep}]+)\\${pathSep}messages\\${pathSep}messages_([^.]+).properties$`
);
crawl({
dirPath: baseThemeDirPath,
returnedPathsType: "relative to dirPath"
}).forEach(filePath => {
const match = filePath.match(re);
if (match === null) {
return;
for (const themeType of THEME_TYPES) {
const { defaultThemeDirPath } = await downloadKeycloakDefaultTheme({
keycloakVersion: (() => {
switch (themeType) {
case "login":
return "25.0.4";
case "account":
return LAST_KEYCLOAK_VERSION_WITH_ACCOUNT_V1;
}
})(),
buildContext: {
cacheDirPath: pathJoin(
thisCodebaseRootDirPath,
"node_modules",
".cache",
"keycloakify"
),
fetchOptions: getProxyFetchOptions({
npmConfigGetCwd: thisCodebaseRootDirPath
})
}
const [, typeOfPage, language] = match;
(record[typeOfPage] ??= {})[language.replace(/_/g, "-")] = Object.fromEntries(
Object.entries(
propertiesParser.parse(
fs
.readFileSync(pathJoin(baseThemeDirPath, filePath))
.toString("utf8")
) as Record<string, string>
)
.map(([key, value]) => [key, value.replace(/''/g, "'")])
.map(([key, value]) => [
key === "locale_pt_BR" ? "locale_pt-BR" : key,
value
])
.map(([key, value]) => [key, key === "termsText" ? "" : value])
);
});
}
Object.keys(record).forEach(themeType => {
if (themeType !== "login" && themeType !== "account") {
return;
{
const baseThemeDirPath = pathJoin(defaultThemeDirPath, "base");
const re = new RegExp(
`^([^\\${pathSep}]+)\\${pathSep}messages\\${pathSep}messages_([^.]+).properties$`
);
crawl({
dirPath: baseThemeDirPath,
returnedPathsType: "relative to dirPath"
}).forEach(filePath => {
const match = filePath.match(re);
if (match === null) {
return;
}
const [, themeType_here, language] = match;
if (themeType_here !== themeType) {
return;
}
(record[themeType] ??= {})[language.replace(/_/g, "-")] =
Object.fromEntries(
Object.entries(
propertiesParser.parse(
fs
.readFileSync(pathJoin(baseThemeDirPath, filePath))
.toString("utf8")
) as Record<string, string>
)
.map(([key, value]) => [key, value.replace(/''/g, "'")])
.map(([key, value]) => [
key === "locale_pt_BR" ? "locale_pt-BR" : key,
value
])
.map(([key, value]) => [
key,
key === "termsText" ? "" : value
])
);
});
}
const recordForThemeType = record[themeType];
@ -99,6 +112,29 @@ async function main() {
assert(false);
})();
/* Migration helper
console.log({ themeType });
{
const all = new Set<string>();
languages.forEach(languages => all.add(languages));
const currentlySupportedLanguages = Object.keys(keycloakifyExtraMessages);
currentlySupportedLanguages.forEach(languages => all.add(languages));
all.forEach(language => {
console.log([
`"${language}": `,
`isInLanguages: ${languages.includes(language)}`,
`isInKeycloakifyExtraMessages: ${currentlySupportedLanguages.includes(language)}`
].join(" "))
});
}
*/
assert(
same(languages, Object.keys(keycloakifyExtraMessages), {
takeIntoAccountArraysOrdering: false
@ -180,7 +216,7 @@ async function main() {
"utf8"
)
);
});
}
}
const keycloakifyExtraMessages_login: Record<
@ -203,6 +239,7 @@ const keycloakifyExtraMessages_login: Record<
| "nl"
| "no"
| "pl"
| "pt"
| "pt-BR"
| "ru"
| "sk"
@ -210,7 +247,9 @@ const keycloakifyExtraMessages_login: Record<
| "th"
| "tr"
| "uk"
| "zh-CN",
| "ka"
| "zh-CN"
| "zh-TW",
Record<
| "shouldBeEqual"
| "shouldBeDifferent"
@ -434,6 +473,17 @@ const keycloakifyExtraMessages_login: Record<
addValue: "Dodaj wartość",
languages: "Języki"
},
pt: {
shouldBeEqual: "{0} deve ser igual a {1}",
shouldBeDifferent: "{0} deve ser diferente de {1}",
shouldMatchPattern: "O padrão deve corresponder: `/{0}/`",
mustBeAnInteger: "Deve ser um número inteiro",
notAValidOption: "Não é uma opção válida",
selectAnOption: "Selecione uma opção",
remove: "Remover",
addValue: "Adicionar valor",
languages: "Idiomas"
},
"pt-BR": {
shouldBeEqual: "{0} deve ser igual a {1}",
shouldBeDifferent: "{0} deve ser diferente de {1}",
@ -511,6 +561,17 @@ const keycloakifyExtraMessages_login: Record<
addValue: "Додати значення",
languages: "Мови"
},
ka: {
shouldBeEqual: "{0} უნდა იყოს ტოლი {1}-სთვის",
shouldBeDifferent: "{0} უნდა იყოს სხვა {1}-სთვის",
shouldMatchPattern: "შაბლონს უნდა ემთხვევა: `/{0}/`",
mustBeAnInteger: "უნდა იყოს მთელი რიცხვი",
notAValidOption: "არასწორი ვარიანტი",
selectAnOption: "აირჩიეთ ვარიანტი",
remove: "წაშალეთ",
addValue: "დაამატეთ მნიშვნელობა",
languages: "ენები"
},
"zh-CN": {
shouldBeEqual: "{0} 应该等于 {1}",
shouldBeDifferent: "{0} 应该不同于 {1}",
@ -521,6 +582,17 @@ const keycloakifyExtraMessages_login: Record<
remove: "移除",
addValue: "添加值",
languages: "语言"
},
"zh-TW": {
shouldBeEqual: "{0} 應該等於 {1}",
shouldBeDifferent: "{0} 應該不同於 {1}",
shouldMatchPattern: "模式應匹配: `/{0}/`",
mustBeAnInteger: "必須是整數",
notAValidOption: "不是有效選項",
selectAnOption: "選擇一個選項",
remove: "移除",
addValue: "添加值",
languages: "語言"
}
/* spell-checker: enable */
};
@ -532,9 +604,7 @@ const keycloakifyExtraMessages_account: Record<
| "cs"
| "da"
| "de"
| "el"
| "es"
| "fa"
| "fi"
| "fr"
| "hu"
@ -549,9 +619,7 @@ const keycloakifyExtraMessages_account: Record<
| "ru"
| "sk"
| "sv"
| "th"
| "tr"
| "uk"
| "zh-CN",
Record<"newPasswordSameAsOld" | "passwordConfirmNotMatch", string>
> = {
@ -580,18 +648,10 @@ const keycloakifyExtraMessages_account: Record<
newPasswordSameAsOld: "Das neue Passwort muss sich vom alten unterscheiden",
passwordConfirmNotMatch: "Passwortbestätigung stimmt nicht überein"
},
el: {
newPasswordSameAsOld: "Ο νέος κωδικός πρόσβασης πρέπει να διαφέρει από τον παλιό",
passwordConfirmNotMatch: "Η επιβεβαίωση του κωδικού πρόσβασης δεν ταιριάζει"
},
es: {
newPasswordSameAsOld: "La nueva contraseña debe ser diferente de la anterior",
passwordConfirmNotMatch: "La confirmación de la contraseña no coincide"
},
fa: {
newPasswordSameAsOld: "رمز عبور جدید باید با رمز عبور قبلی متفاوت باشد",
passwordConfirmNotMatch: "تأیید رمز عبور مطابقت ندارد"
},
fi: {
newPasswordSameAsOld: "Uusi salasana on oltava erilainen kuin vanha",
passwordConfirmNotMatch: "Salasanan vahvistus ei täsmää"
@ -649,18 +709,10 @@ const keycloakifyExtraMessages_account: Record<
newPasswordSameAsOld: "Det nya lösenordet måste skilja sig från det gamla",
passwordConfirmNotMatch: "Lösenordsbekräftelsen matchar inte"
},
th: {
newPasswordSameAsOld: "รหัสผ่านใหม่ต้องต่างจากรหัสผ่านเดิม",
passwordConfirmNotMatch: "การยืนยันรหัสผ่านไม่ตรงกัน"
},
tr: {
newPasswordSameAsOld: "Yeni şifre eskisinden farklı olmalıdır",
passwordConfirmNotMatch: "Şifre doğrulama eşleşmiyor"
},
uk: {
newPasswordSameAsOld: "Новий пароль повинен відрізнятися від старого",
passwordConfirmNotMatch: "Підтвердження пароля не співпадає"
},
"zh-CN": {
newPasswordSameAsOld: "新密码必须与旧密码不同",
passwordConfirmNotMatch: "密码确认不匹配"

View File

@ -2,8 +2,33 @@ import * as child_process from "child_process";
import * as fs from "fs";
import { join } from "path";
import { startRebuildOnSrcChange } from "./startRebuildOnSrcChange";
import { crawl } from "../src/bin/tools/crawl";
{
const dirPath = "node_modules";
try {
fs.rmSync(dirPath, { recursive: true, force: true });
} catch {
// NOTE: This is a workaround for windows
// we can't remove locked executables.
crawl({
dirPath,
returnedPathsType: "absolute"
}).forEach(filePath => {
try {
fs.rmSync(filePath, { force: true });
} catch (error) {
if (filePath.endsWith(".exe")) {
return;
}
throw error;
}
});
}
}
fs.rmSync("node_modules", { recursive: true, force: true });
fs.rmSync("dist", { recursive: true, force: true });
fs.rmSync(".yarn_home", { recursive: true, force: true });

View File

@ -26,11 +26,43 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
console.log(chalk.cyan("Theme type:"));
const { value: themeType } = await cliSelect<ThemeType>({
values: [...THEME_TYPES]
}).catch(() => {
process.exit(-1);
});
const themeType = await (async () => {
const values = THEME_TYPES.filter(themeType => {
switch (themeType) {
case "account":
return buildContext.implementedThemeTypes.account.isImplemented;
case "login":
return buildContext.implementedThemeTypes.login.isImplemented;
}
assert<Equals<typeof themeType, never>>(false);
});
assert(values.length > 0, "No theme is implemented in this project");
if (values.length === 1) {
return values[0];
}
const { value } = await cliSelect<ThemeType>({
values
}).catch(() => {
process.exit(-1);
});
return value;
})();
if (
themeType === "account" &&
(assert(buildContext.implementedThemeTypes.account.isImplemented),
buildContext.implementedThemeTypes.account.type === "Single-Page")
) {
console.log(
`${chalk.red("✗")} Sorry, there is no Storybook support for Single-Page Account themes.`
);
process.exit(0);
}
console.log(`${themeType}`);

View File

@ -12,7 +12,12 @@ import {
} from "./shared/constants";
import { capitalize } from "tsafe/capitalize";
import * as fs from "fs";
import { join as pathJoin, relative as pathRelative, dirname as pathDirname } from "path";
import {
join as pathJoin,
relative as pathRelative,
dirname as pathDirname,
basename as pathBasename
} from "path";
import { kebabCaseToCamelCase } from "./tools/kebabCaseToSnakeCase";
import { assert, Equals } from "tsafe/assert";
import type { CliCommandOptions } from "./main";
@ -28,11 +33,114 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
console.log(chalk.cyan("Theme type:"));
const { value: themeType } = await cliSelect<ThemeType>({
values: [...THEME_TYPES]
}).catch(() => {
process.exit(-1);
});
const themeType = await (async () => {
const values = THEME_TYPES.filter(themeType => {
switch (themeType) {
case "account":
return buildContext.implementedThemeTypes.account.isImplemented;
case "login":
return buildContext.implementedThemeTypes.login.isImplemented;
}
assert<Equals<typeof themeType, never>>(false);
});
assert(values.length > 0, "No theme is implemented in this project");
if (values.length === 1) {
return values[0];
}
const { value } = await cliSelect<ThemeType>({
values
}).catch(() => {
process.exit(-1);
});
return value;
})();
if (
themeType === "account" &&
(assert(buildContext.implementedThemeTypes.account.isImplemented),
buildContext.implementedThemeTypes.account.type === "Single-Page")
) {
const srcDirPath = pathJoin(
pathDirname(buildContext.packageJsonFilePath),
"node_modules",
"@keycloakify",
"keycloak-account-ui",
"src"
);
console.log(
[
`There isn't an interactive CLI to eject components of the Single-Page Account theme.`,
`You can however copy paste into your codebase the any file or directory from the following source directory:`,
``,
`${chalk.bold(pathJoin(pathRelative(process.cwd(), srcDirPath)))}`,
``
].join("\n")
);
eject_entrypoint: {
const kcAccountUiTsxFileRelativePath = "KcAccountUi.tsx";
const accountThemeSrcDirPath = pathJoin(
buildContext.themeSrcDirPath,
"account"
);
const targetFilePath = pathJoin(
accountThemeSrcDirPath,
kcAccountUiTsxFileRelativePath
);
if (fs.existsSync(targetFilePath)) {
break eject_entrypoint;
}
fs.cpSync(
pathJoin(srcDirPath, kcAccountUiTsxFileRelativePath),
targetFilePath
);
{
const kcPageTsxFilePath = pathJoin(accountThemeSrcDirPath, "KcPage.tsx");
const kcPageTsxCode = fs.readFileSync(kcPageTsxFilePath).toString("utf8");
const componentName = pathBasename(
kcAccountUiTsxFileRelativePath
).replace(/.tsx$/, "");
const modifiedKcPageTsxCode = kcPageTsxCode.replace(
`@keycloakify/keycloak-account-ui/${componentName}`,
`./${componentName}`
);
fs.writeFileSync(
kcPageTsxFilePath,
Buffer.from(modifiedKcPageTsxCode, "utf8")
);
}
const routesTsxFilePath = pathRelative(
process.cwd(),
pathJoin(srcDirPath, "routes.tsx")
);
console.log(
[
`To help you get started ${chalk.bold(pathRelative(process.cwd(), targetFilePath))} has been copied into your project.`,
`The next step is usually to eject ${chalk.bold(routesTsxFilePath)}`,
`with \`cp ${routesTsxFilePath} ${pathRelative(process.cwd(), accountThemeSrcDirPath)}\``,
`then update the import of routes in ${kcAccountUiTsxFileRelativePath}.`
].join("\n")
);
}
process.exit(0);
}
console.log(`${themeType}`);

View File

@ -2,7 +2,10 @@ import { join as pathJoin, relative as pathRelative, dirname as pathDirname } fr
import type { BuildContext } from "../shared/buildContext";
import * as fs from "fs";
import chalk from "chalk";
import { getLatestsSemVersionedTag } from "../shared/getLatestsSemVersionedTag";
import {
getLatestsSemVersionedTag,
type BuildContextLike as BuildContextLike_getLatestsSemVersionedTag
} from "../shared/getLatestsSemVersionedTag";
import fetch from "make-fetch-happen";
import { z } from "zod";
import { assert, type Equals } from "tsafe/assert";
@ -12,8 +15,7 @@ import { npmInstall } from "../tools/npmInstall";
import { copyBoilerplate } from "./copyBoilerplate";
import { getThisCodebaseRootDirPath } from "../tools/getThisCodebaseRootDirPath";
type BuildContextLike = {
cacheDirPath: string;
type BuildContextLike = BuildContextLike_getLatestsSemVersionedTag & {
fetchOptions: BuildContext["fetchOptions"];
packageJsonFilePath: string;
};
@ -30,11 +32,11 @@ export async function initializeAccountTheme_singlePage(params: {
const REPO = "keycloak-account-ui";
const [semVersionedTag] = await getLatestsSemVersionedTag({
cacheDirPath: buildContext.cacheDirPath,
owner: OWNER,
repo: REPO,
count: 1,
doIgnoreReleaseCandidates: false
doIgnoreReleaseCandidates: false,
buildContext
});
const dependencies = await fetch(

View File

@ -60,14 +60,14 @@ export function updateAccountThemeImplementationInConfig(params: {
{
const parsedPackageJson = (() => {
type ParsedPackageJson = {
keycloakify: Record<string, string>;
keycloakify: Record<string, unknown>;
};
const zParsedPackageJson = (() => {
type TargetType = ParsedPackageJson;
const zTargetType = z.object({
keycloakify: z.record(z.string())
keycloakify: z.record(z.unknown())
});
assert<Equals<z.infer<typeof zTargetType>, TargetType>>();
@ -75,17 +75,22 @@ export function updateAccountThemeImplementationInConfig(params: {
return id<z.ZodType<TargetType>>(zTargetType);
})();
return zParsedPackageJson.parse(
JSON.parse(
fs
.readFileSync(buildContext.packageJsonFilePath)
.toString("utf8")
)
const parsedPackageJson = JSON.parse(
fs.readFileSync(buildContext.packageJsonFilePath).toString("utf8")
);
zParsedPackageJson.parse(parsedPackageJson);
return parsedPackageJson;
})();
parsedPackageJson.keycloakify.accountThemeImplementation =
accountThemeType;
fs.writeFileSync(
buildContext.packageJsonFilePath,
Buffer.from(JSON.stringify(parsedPackageJson, undefined, 4), "utf8")
);
}
break;
}

View File

@ -30,7 +30,8 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
// NOTE: This is arbitrary
startingFromMajor: 17,
excludeMajorVersions: [],
cacheDirPath: buildContext.cacheDirPath
doOmitPatch: false,
buildContext
});
const { defaultThemeDirPath } = await downloadKeycloakDefaultTheme({

View File

@ -139,25 +139,6 @@ export async function buildJar(params: {
break route_legacy_pages;
}
// NOTE: If there's no account theme there is no special target for keycloak 24 and up so we create
// the pages anyway. If there is an account pages, since we know that account-v1 is only support keycloak
// 24 in version 0.4 and up, we can safely break the route for legacy pages.
const doBreak: boolean = (() => {
switch (keycloakAccountV1Version) {
case null:
return false;
case "0.3":
return false;
default:
return true;
}
})();
// TODO: Remove this optimization, it's a bit hacky.
if (doBreak) {
break route_legacy_pages;
}
(["register.ftl", "login-update-profile.ftl"] as const).forEach(pageId =>
buildContext.themeNames.map(themeName => {
const ftlFilePath = pathJoin(

View File

@ -1,4 +1,4 @@
import cheerio from "cheerio";
import * as cheerio from "cheerio";
import {
replaceImportsInJsCode,
BuildContextLike as BuildContextLike_replaceImportsInJsCode
@ -77,7 +77,8 @@ export function generateFtlFilesCodeFactory(params: {
(
[
["link", "href"],
["script", "src"]
["script", "src"],
["script", "data-src"]
] as const
).forEach(([selector, attrName]) =>
$(selector).each((...[, element]) => {

View File

@ -208,6 +208,18 @@ function decodeHtmlEntities(htmlStr){
) || (
key == "attributes" &&
areSamePath(path, ["realm"])
) || (
xKeycloakify.pageId == "index.ftl" &&
xKeycloakify.themeType == "account" &&
areSamePath(path, ["realm"]) &&
![
"name",
"registrationEmailAsUsername",
"editUsernameAllowed",
"isInternationalizationEnabled",
"identityFederationEnabled",
"userManagedAccessAllowed"
]?seq_contains(key)
)
>
<#-- <#local outSeq += ["/*" + path?join(".") + "." + key + " excluded*/"]> -->

View File

@ -78,7 +78,7 @@ program
program
.command<{
port: number;
port: number | undefined;
keycloakVersion: string | undefined;
realmJsonFilePath: string | undefined;
}>({
@ -96,7 +96,7 @@ program
return name;
})(),
description: ["Keycloak server port.", "Example `--port 8085`"].join(" "),
defaultValue: 8080
defaultValue: undefined
})
.option({
key: "keycloakVersion",

View File

@ -15,7 +15,7 @@ import * as child_process from "child_process";
import {
VITE_PLUGIN_SUB_SCRIPTS_ENV_NAMES,
BUILD_FOR_KEYCLOAK_MAJOR_VERSION_ENV_NAME,
LOGIN_THEME_RESOURCES_FROMkEYCLOAK_VERSION_DEFAULT
LOGIN_THEME_RESOURCES_FROM_KEYCLOAK_VERSION_DEFAULT
} from "./constants";
import type { KeycloakVersionRange } from "./KeycloakVersionRange";
import { exclude } from "tsafe";
@ -61,6 +61,19 @@ export type BuildContext = {
keycloakVersionRange: KeycloakVersionRange;
jarFileBasename: string;
}[];
startKeycloakOptions: {
dockerImage:
| {
reference: string;
tag: string;
}
| undefined;
dockerExtraArgs: string[];
keycloakExtraArgs: string[];
extensionJars: ({ type: "path"; path: string } | { type: "url"; url: string })[];
realmJsonFilePath: string | undefined;
port: number | undefined;
};
};
assert<Equals<keyof BuildContext["implementedThemeTypes"], ThemeType | "email">>();
@ -75,6 +88,14 @@ export type BuildOptions = {
loginThemeResourcesFromKeycloakVersion?: string;
keycloakifyBuildDirPath?: string;
kcContextExclusionsFtl?: string;
startKeycloakOptions?: {
dockerImage?: string;
dockerExtraArgs?: string[];
keycloakExtraArgs?: string[];
extensionJars?: string[];
realmJsonFilePath?: string;
port?: number;
};
} & BuildOptions.AccountThemeImplAndKeycloakVersionTargets;
export namespace BuildOptions {
@ -301,6 +322,23 @@ export function getBuildContext(params: {
return id<z.ZodType<TargetType>>(zTargetType);
})();
const zStartKeycloakOptions = (() => {
type TargetType = NonNullable<BuildOptions["startKeycloakOptions"]>;
const zTargetType = z.object({
dockerImage: z.string().optional(),
extensionJars: z.array(z.string()).optional(),
realmJsonFilePath: z.string().optional(),
dockerExtraArgs: z.array(z.string()).optional(),
keycloakExtraArgs: z.array(z.string()).optional(),
port: z.number().optional()
});
assert<Equals<z.infer<typeof zTargetType>, TargetType>>();
return id<z.ZodType<TargetType>>(zTargetType);
})();
const zBuildOptions = (() => {
type TargetType = BuildOptions;
@ -321,7 +359,8 @@ export function getBuildContext(params: {
groupId: z.string().optional(),
loginThemeResourcesFromKeycloakVersion: z.string().optional(),
keycloakifyBuildDirPath: z.string().optional(),
kcContextExclusionsFtl: z.string().optional()
kcContextExclusionsFtl: z.string().optional(),
startKeycloakOptions: zStartKeycloakOptions.optional()
}),
zAccountThemeImplAndKeycloakVersionTargets
);
@ -508,7 +547,7 @@ export function getBuildContext(params: {
`${themeNames[0]}-keycloak-theme`,
loginThemeResourcesFromKeycloakVersion:
buildOptions.loginThemeResourcesFromKeycloakVersion ??
LOGIN_THEME_RESOURCES_FROMkEYCLOAK_VERSION_DEFAULT,
LOGIN_THEME_RESOURCES_FROM_KEYCLOAK_VERSION_DEFAULT,
projectDirPath,
projectBuildDirPath,
keycloakifyBuildDirPath: (() => {
@ -891,6 +930,48 @@ export function getBuildContext(params: {
}
return jarTargets;
})()
})(),
startKeycloakOptions: {
dockerImage: (() => {
if (buildOptions.startKeycloakOptions?.dockerImage === undefined) {
return undefined;
}
const [reference, tag, ...rest] =
buildOptions.startKeycloakOptions.dockerImage.split(":");
assert(
reference !== undefined && tag !== undefined && rest.length === 0,
`Invalid docker image: ${buildOptions.startKeycloakOptions.dockerImage}`
);
return { reference, tag };
})(),
dockerExtraArgs: buildOptions.startKeycloakOptions?.dockerExtraArgs ?? [],
keycloakExtraArgs: buildOptions.startKeycloakOptions?.keycloakExtraArgs ?? [],
extensionJars: (buildOptions.startKeycloakOptions?.extensionJars ?? []).map(
urlOrPath => {
if (/^https?:\/\//.test(urlOrPath)) {
return { type: "url", url: urlOrPath };
}
return {
type: "path",
path: getAbsoluteAndInOsFormatPath({
pathIsh: urlOrPath,
cwd: projectDirPath
})
};
}
),
realmJsonFilePath:
buildOptions.startKeycloakOptions?.realmJsonFilePath === undefined
? undefined
: getAbsoluteAndInOsFormatPath({
pathIsh: buildOptions.startKeycloakOptions.realmJsonFilePath,
cwd: projectDirPath
}),
port: buildOptions.startKeycloakOptions?.port
}
};
}

View File

@ -50,7 +50,9 @@ export const LOGIN_THEME_PAGE_IDS = [
"login-recovery-authn-code-input.ftl",
"login-reset-otp.ftl",
"login-x509-info.ftl",
"webauthn-error.ftl"
"webauthn-error.ftl",
"login-passkeys-conditional-authenticate.ftl",
"login-idp-link-confirm-override.ftl"
] as const;
export const ACCOUNT_THEME_PAGE_IDS = [
@ -70,4 +72,4 @@ export const CONTAINER_NAME = "keycloak-keycloakify";
export const FALLBACK_LANGUAGE_TAG = "en";
export const LOGIN_THEME_RESOURCES_FROMkEYCLOAK_VERSION_DEFAULT = "24.0.4";
export const LOGIN_THEME_RESOURCES_FROM_KEYCLOAK_VERSION_DEFAULT = "24.0.4";

View File

@ -1,8 +1,10 @@
import { join as pathJoin, relative as pathRelative } from "path";
import { type BuildContext } from "./buildContext";
import { type BuildContext } from "../buildContext";
import { assert } from "tsafe/assert";
import { LAST_KEYCLOAK_VERSION_WITH_ACCOUNT_V1 } from "./constants";
import { downloadAndExtractArchive } from "../tools/downloadAndExtractArchive";
import { LAST_KEYCLOAK_VERSION_WITH_ACCOUNT_V1 } from "../constants";
import { downloadAndExtractArchive } from "../../tools/downloadAndExtractArchive";
import { getThisCodebaseRootDirPath } from "../../tools/getThisCodebaseRootDirPath";
import * as fsPr from "fs/promises";
export type BuildContextLike = {
cacheDirPath: string;
@ -20,6 +22,8 @@ export async function downloadKeycloakDefaultTheme(params: {
let kcNodeModulesKeepFilePaths: Set<string> | undefined = undefined;
let kcNodeModulesKeepFilePaths_lastAccountV1: Set<string> | undefined = undefined;
let areExtraAssetsFor24Copied = false;
const { extractedDirPath } = await downloadAndExtractArchive({
url: `https://repo1.maven.org/maven2/org/keycloak/keycloak-themes/${keycloakVersion}/keycloak-themes-${keycloakVersion}.jar`,
cacheDirPath: buildContext.cacheDirPath,
@ -32,8 +36,6 @@ export async function downloadKeycloakDefaultTheme(params: {
return;
}
const { readFile, writeFile } = params;
skip_keycloak_v2: {
if (!fileRelativePath.startsWith(pathJoin("keycloak.v2"))) {
break skip_keycloak_v2;
@ -42,6 +44,8 @@ export async function downloadKeycloakDefaultTheme(params: {
return;
}
const { readFile, writeFile } = params;
last_account_v1_transformations: {
if (LAST_KEYCLOAK_VERSION_WITH_ACCOUNT_V1 !== keycloakVersion) {
break last_account_v1_transformations;
@ -168,6 +172,43 @@ export async function downloadKeycloakDefaultTheme(params: {
}
}
copy_extra_assets: {
if (keycloakVersion !== "24.0.4") {
break copy_extra_assets;
}
if (areExtraAssetsFor24Copied) {
break copy_extra_assets;
}
const extraAssetsDirPath = pathJoin(
getThisCodebaseRootDirPath(),
"src",
"bin",
"shared",
"downloadKeycloakDefaultTheme",
"extra-assets"
);
await Promise.all(
["webauthnAuthenticate.js", "passkeysConditionalAuth.js"].map(
async fileBasename =>
writeFile({
fileRelativePath: pathJoin(
"base",
"login",
"resources",
"js",
fileBasename
),
modifiedData: await fsPr.readFile(
pathJoin(extraAssetsDirPath, fileBasename)
)
})
)
);
}
skip_unused_resources: {
if (keycloakVersion !== "24.0.4") {
break skip_unused_resources;

View File

@ -0,0 +1,79 @@
import { base64url } from "rfc4648";
import { returnSuccess, returnFailure } from "./webauthnAuthenticate.js";
export function initAuthenticate(input) {
// Check if WebAuthn is supported by this browser
if (!window.PublicKeyCredential) {
returnFailure(input.errmsg);
return;
}
if (input.isUserIdentified || typeof PublicKeyCredential.isConditionalMediationAvailable === "undefined") {
document.getElementById("kc-form-passkey-button").style.display = 'block';
} else {
tryAutoFillUI(input);
}
}
function doAuthenticate(input) {
// Check if WebAuthn is supported by this browser
if (!window.PublicKeyCredential) {
returnFailure(input.errmsg);
return;
}
const publicKey = {
rpId : input.rpId,
challenge: base64url.parse(input.challenge, { loose: true })
};
publicKey.allowCredentials = !input.isUserIdentified ? [] : getAllowCredentials();
if (input.createTimeout !== 0) {
publicKey.timeout = input.createTimeout * 1000;
}
if (input.userVerification !== 'not specified') {
publicKey.userVerification = input.userVerification;
}
return navigator.credentials.get({
publicKey: publicKey,
...input.additionalOptions
});
}
async function tryAutoFillUI(input) {
const isConditionalMediationAvailable = await PublicKeyCredential.isConditionalMediationAvailable();
if (isConditionalMediationAvailable) {
document.getElementById("kc-form-login").style.display = "block";
input.additionalOptions = { mediation: 'conditional'};
try {
const result = await doAuthenticate(input);
returnSuccess(result);
} catch (error) {
returnFailure(error);
}
} else {
document.getElementById("kc-form-passkey-button").style.display = 'block';
}
}
function getAllowCredentials() {
const allowCredentials = [];
const authnUse = document.forms['authn_select'].authn_use_chk;
if (authnUse !== undefined) {
if (authnUse.length === undefined) {
allowCredentials.push({
id: base64url.parse(authnUse.value, {loose: true}),
type: 'public-key',
});
} else {
authnUse.forEach((entry) =>
allowCredentials.push({
id: base64url.parse(entry.value, {loose: true}),
type: 'public-key',
}));
}
}
return allowCredentials;
}

View File

@ -0,0 +1,82 @@
import { base64url } from "rfc4648";
export async function authenticateByWebAuthn(input) {
if (!input.isUserIdentified) {
try {
const result = await doAuthenticate([], input.challenge, input.userVerification, input.rpId, input.createTimeout, input.errmsg);
returnSuccess(result);
} catch (error) {
returnFailure(error);
}
return;
}
checkAllowCredentials(input.challenge, input.userVerification, input.rpId, input.createTimeout, input.errmsg);
}
async function checkAllowCredentials(challenge, userVerification, rpId, createTimeout, errmsg) {
const allowCredentials = [];
const authnUse = document.forms['authn_select'].authn_use_chk;
if (authnUse !== undefined) {
if (authnUse.length === undefined) {
allowCredentials.push({
id: base64url.parse(authnUse.value, {loose: true}),
type: 'public-key',
});
} else {
authnUse.forEach((entry) =>
allowCredentials.push({
id: base64url.parse(entry.value, {loose: true}),
type: 'public-key',
}));
}
}
try {
const result = await doAuthenticate(allowCredentials, challenge, userVerification, rpId, createTimeout, errmsg);
returnSuccess(result);
} catch (error) {
returnFailure(error);
}
}
function doAuthenticate(allowCredentials, challenge, userVerification, rpId, createTimeout, errmsg) {
// Check if WebAuthn is supported by this browser
if (!window.PublicKeyCredential) {
returnFailure(errmsg);
return;
}
const publicKey = {
rpId : rpId,
challenge: base64url.parse(challenge, { loose: true })
};
if (createTimeout !== 0) {
publicKey.timeout = createTimeout * 1000;
}
if (allowCredentials.length) {
publicKey.allowCredentials = allowCredentials;
}
if (userVerification !== 'not specified') {
publicKey.userVerification = userVerification;
}
return navigator.credentials.get({publicKey});
}
export function returnSuccess(result) {
document.getElementById("clientDataJSON").value = base64url.stringify(new Uint8Array(result.response.clientDataJSON), { pad: false });
document.getElementById("authenticatorData").value = base64url.stringify(new Uint8Array(result.response.authenticatorData), { pad: false });
document.getElementById("signature").value = base64url.stringify(new Uint8Array(result.response.signature), { pad: false });
document.getElementById("credentialId").value = result.id;
if (result.response.userHandle) {
document.getElementById("userHandle").value = base64url.stringify(new Uint8Array(result.response.userHandle), { pad: false });
}
document.getElementById("webauth").submit();
}
export function returnFailure(err) {
document.getElementById("error").value = err;
document.getElementById("webauth").submit();
}

View File

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

View File

@ -9,6 +9,8 @@ import { assert, type Equals } from "tsafe/assert";
import { id } from "tsafe/id";
import type { SemVer } from "../tools/SemVer";
import { same } from "evt/tools/inDepth/same";
import type { BuildContext } from "./buildContext";
import fetch from "make-fetch-happen";
type GetLatestsSemVersionedTag = ReturnType<
typeof getLatestsSemVersionedTagFactory
@ -31,11 +33,23 @@ type Cache = {
}[];
};
export type BuildContextLike = {
cacheDirPath: string;
fetchOptions: BuildContext["fetchOptions"];
};
assert<BuildContext extends BuildContextLike ? true : false>();
export async function getLatestsSemVersionedTag({
cacheDirPath,
buildContext,
...params
}: Params & { cacheDirPath: string }): Promise<R> {
const cacheFilePath = pathJoin(cacheDirPath, "latest-sem-versioned-tags.json");
}: Params & {
buildContext: BuildContextLike;
}): Promise<R> {
const cacheFilePath = pathJoin(
buildContext.cacheDirPath,
"latest-sem-versioned-tags.json"
);
const cacheLookupResult = (() => {
const getResult_currentCache = (currentCacheEntries: Cache["entries"]) => ({
@ -144,9 +158,16 @@ export async function getLatestsSemVersionedTag({
const octokit = (() => {
const githubToken = process.env.GITHUB_TOKEN;
const octokit = new Octokit(
githubToken === undefined ? undefined : { auth: githubToken }
);
const octokit = new Octokit({
...(githubToken === undefined ? {} : { auth: githubToken }),
request: {
fetch: (url: string, options?: any) =>
fetch(url, {
...options,
...buildContext.fetchOptions
})
}
});
return octokit;
})();

View File

@ -1,22 +1,32 @@
import { getLatestsSemVersionedTag } from "./getLatestsSemVersionedTag";
import {
getLatestsSemVersionedTag,
type BuildContextLike as BuildContextLike_getLatestsSemVersionedTag
} from "./getLatestsSemVersionedTag";
import cliSelect from "cli-select";
import { assert } from "tsafe/assert";
import { SemVer } from "../tools/SemVer";
import type { BuildContext } from "./buildContext";
export type BuildContextLike = BuildContextLike_getLatestsSemVersionedTag & {};
assert<BuildContext extends BuildContextLike ? true : false>();
export async function promptKeycloakVersion(params: {
startingFromMajor: number | undefined;
excludeMajorVersions: number[];
cacheDirPath: string;
doOmitPatch: boolean;
buildContext: BuildContextLike;
}) {
const { startingFromMajor, excludeMajorVersions, cacheDirPath } = params;
const { startingFromMajor, excludeMajorVersions, doOmitPatch, buildContext } = params;
const semVersionedTagByMajor = new Map<number, { tag: string; version: SemVer }>();
const semVersionedTags = await getLatestsSemVersionedTag({
cacheDirPath,
count: 50,
owner: "keycloak",
repo: "keycloak",
doIgnoreReleaseCandidates: true
doIgnoreReleaseCandidates: true,
buildContext
});
semVersionedTags.forEach(semVersionedTag => {
@ -46,7 +56,8 @@ export async function promptKeycloakVersion(params: {
});
const lastMajorVersions = Array.from(semVersionedTagByMajor.values()).map(
({ tag }) => tag
({ version }) =>
`${version.major}.${version.minor}${doOmitPatch ? "" : `.${version.patch}`}`
);
const { value } = await cliSelect<string>({

View File

@ -40,7 +40,7 @@ async function appBuild_vite(params: {
const dIsSuccess = new Deferred<boolean>();
console.log(chalk.blue("Running: 'npx vite build'"));
console.log(chalk.blue("$ npx vite build"));
const child = child_process.spawn("npx", ["vite", "build"], {
cwd: buildContext.projectDirPath,
@ -145,7 +145,7 @@ async function appBuild_webpack(params: {
continue;
}
console.log(chalk.blue(`Running: '${subCommand}'`));
console.log(chalk.blue(`$ ${subCommand}`));
const child = child_process.spawn(command, args, {
cwd: commandCwd,

View File

@ -20,7 +20,7 @@ export async function keycloakifyBuild(params: {
const dResult = new Deferred<{ isSuccess: boolean }>();
console.log(chalk.blue("Running: 'npx keycloakify build'"));
console.log(chalk.blue("$ npx keycloakify build"));
const child = child_process.spawn("npx", ["keycloakify", "build"], {
cwd: buildContext.projectDirPath,

View File

@ -73,7 +73,7 @@
"composites": {
"realm": ["offline_access", "uma_authorization"],
"client": {
"account": ["view-profile", "manage-account"]
"account": ["delete-account", "view-profile", "manage-account"]
}
},
"clientRole": false,
@ -611,8 +611,8 @@
"name": "",
"description": "",
"rootUrl": "https://my-theme.keycloakify.dev",
"adminUrl": "",
"baseUrl": "",
"adminUrl": "https://my-theme.keycloakify.dev",
"baseUrl": "https://my-theme.keycloakify.dev",
"surrogateAuthRequired": false,
"enabled": true,
"alwaysDisplayInConsole": false,
@ -2099,7 +2099,7 @@
"alias": "delete_account",
"name": "Delete Account",
"providerId": "delete_account",
"enabled": false,
"enabled": true,
"defaultAction": false,
"priority": 60,
"config": {}

View File

@ -73,7 +73,7 @@
"composites": {
"realm": ["offline_access", "uma_authorization"],
"client": {
"account": ["view-profile", "manage-account"]
"account": ["delete-account", "view-profile", "manage-account"]
}
},
"clientRole": false,
@ -618,8 +618,8 @@
"name": "",
"description": "",
"rootUrl": "https://my-theme.keycloakify.dev",
"adminUrl": "",
"baseUrl": "",
"adminUrl": "https://my-theme.keycloakify.dev",
"baseUrl": "https://my-theme.keycloakify.dev",
"surrogateAuthRequired": false,
"enabled": true,
"alwaysDisplayInConsole": false,
@ -2130,7 +2130,7 @@
"alias": "delete_account",
"name": "Delete Account",
"providerId": "delete_account",
"enabled": false,
"enabled": true,
"defaultAction": false,
"priority": 60,
"config": {}

View File

@ -73,7 +73,7 @@
"composites": {
"realm": ["offline_access", "uma_authorization"],
"client": {
"account": ["view-profile", "manage-account"]
"account": ["delete-account", "view-profile", "manage-account"]
}
},
"clientRole": false,
@ -628,8 +628,8 @@
"name": "",
"description": "",
"rootUrl": "https://my-theme.keycloakify.dev",
"adminUrl": "",
"baseUrl": "",
"adminUrl": "https://my-theme.keycloakify.dev",
"baseUrl": "https://my-theme.keycloakify.dev",
"surrogateAuthRequired": false,
"enabled": true,
"alwaysDisplayInConsole": false,
@ -2140,7 +2140,7 @@
"alias": "delete_account",
"name": "Delete Account",
"providerId": "delete_account",
"enabled": false,
"enabled": true,
"defaultAction": false,
"priority": 60,
"config": {}

View File

@ -73,7 +73,7 @@
"composites": {
"realm": ["offline_access", "uma_authorization"],
"client": {
"account": ["view-profile", "manage-account"]
"account": ["delete-account", "view-profile", "manage-account"]
}
},
"clientRole": false,
@ -632,8 +632,8 @@
"name": "",
"description": "",
"rootUrl": "https://my-theme.keycloakify.dev",
"adminUrl": "",
"baseUrl": "",
"adminUrl": "https://my-theme.keycloakify.dev",
"baseUrl": "https://my-theme.keycloakify.dev",
"surrogateAuthRequired": false,
"enabled": true,
"alwaysDisplayInConsole": false,
@ -2144,7 +2144,7 @@
"alias": "delete_account",
"name": "Delete Account",
"providerId": "delete_account",
"enabled": false,
"enabled": true,
"defaultAction": false,
"priority": 60,
"config": {}

View File

@ -55,7 +55,7 @@
"composites": {
"realm": ["offline_access", "uma_authorization"],
"client": {
"account": ["view-profile", "manage-account"]
"account": ["delete-account", "view-profile", "manage-account"]
}
},
"clientRole": false,
@ -644,7 +644,7 @@
"description": "",
"rootUrl": "https://my-theme.keycloakify.dev",
"adminUrl": "https://my-theme.keycloakify.dev",
"baseUrl": "",
"baseUrl": "https://my-theme.keycloakify.dev",
"surrogateAuthRequired": false,
"enabled": true,
"alwaysDisplayInConsole": false,
@ -2088,7 +2088,7 @@
"alias": "delete_account",
"name": "Delete Account",
"providerId": "delete_account",
"enabled": false,
"enabled": true,
"defaultAction": false,
"priority": 60,
"config": {}

View File

@ -63,7 +63,7 @@
"composites": {
"realm": ["offline_access", "uma_authorization"],
"client": {
"account": ["manage-account", "view-profile"]
"account": ["delete-account", "manage-account", "view-profile"]
}
},
"clientRole": false,
@ -449,7 +449,7 @@
"gender": ["prefer_not_to_say"],
"bio": ["Hello I'm Test User and I do not exist."],
"phone_number": ["1111111111"],
"locale": ["fr"],
"locale": ["en"],
"favorite_media": ["movies", "series"]
},
"createdTimestamp": 1716183898408,
@ -653,7 +653,7 @@
"description": "",
"rootUrl": "https://my-theme.keycloakify.dev",
"adminUrl": "https://my-theme.keycloakify.dev",
"baseUrl": "",
"baseUrl": "https://my-theme.keycloakify.dev",
"surrogateAuthRequired": false,
"enabled": true,
"alwaysDisplayInConsole": false,
@ -2235,7 +2235,7 @@
"alias": "delete_account",
"name": "Delete Account",
"providerId": "delete_account",
"enabled": false,
"enabled": true,
"defaultAction": false,
"priority": 60,
"config": {}

View File

@ -63,7 +63,7 @@
"composites": {
"realm": ["offline_access", "uma_authorization"],
"client": {
"account": ["manage-account", "view-profile"]
"account": ["delete-account", "manage-account", "view-profile"]
}
},
"clientRole": false,
@ -543,7 +543,7 @@
"favourite_pet": ["cat"],
"bio": ["Hello I'm Test User and I do not exist."],
"phone_number": ["1111111111"],
"locale": ["fr"],
"locale": ["en"],
"favorite_media": ["movies", "series"]
},
"createdTimestamp": 1716183898408,
@ -767,7 +767,7 @@
"description": "",
"rootUrl": "https://my-theme.keycloakify.dev",
"adminUrl": "https://my-theme.keycloakify.dev",
"baseUrl": "",
"baseUrl": "https://my-theme.keycloakify.dev",
"surrogateAuthRequired": false,
"enabled": true,
"alwaysDisplayInConsole": false,
@ -2315,7 +2315,7 @@
"alias": "delete_account",
"name": "Delete Account",
"providerId": "delete_account",
"enabled": false,
"enabled": true,
"defaultAction": false,
"priority": 60,
"config": {}

View File

@ -4,13 +4,14 @@ import type { CliCommandOptions as CliCommandOptions_common } from "../main";
import { promptKeycloakVersion } from "../shared/promptKeycloakVersion";
import { ACCOUNT_V1_THEME_NAME, CONTAINER_NAME } from "../shared/constants";
import { SemVer } from "../tools/SemVer";
import { assert } from "tsafe/assert";
import { assert, type Equals } from "tsafe/assert";
import * as fs from "fs";
import {
join as pathJoin,
relative as pathRelative,
sep as pathSep,
basename as pathBasename
basename as pathBasename,
dirname as pathDirname
} from "path";
import * as child_process from "child_process";
import chalk from "chalk";
@ -26,9 +27,10 @@ import { keycloakifyBuild } from "./keycloakifyBuild";
import { isInside } from "../tools/isInside";
import { existsAsync } from "../tools/fs.existsAsync";
import { rm } from "../tools/fs.rm";
import { downloadAndExtractArchive } from "../tools/downloadAndExtractArchive";
export type CliCommandOptions = CliCommandOptions_common & {
port: number;
port: number | undefined;
keycloakVersion: string | undefined;
realmJsonFilePath: string | undefined;
};
@ -88,30 +90,66 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
const buildContext = getBuildContext({ cliCommandOptions });
const { keycloakVersion } = await (async () => {
const { dockerImageTag } = await (async () => {
if (cliCommandOptions.keycloakVersion !== undefined) {
return { dockerImageTag: cliCommandOptions.keycloakVersion };
}
if (buildContext.startKeycloakOptions.dockerImage !== undefined) {
return {
keycloakVersion: cliCommandOptions.keycloakVersion,
keycloakMajorNumber: SemVer.parse(cliCommandOptions.keycloakVersion).major
dockerImageTag: buildContext.startKeycloakOptions.dockerImage.tag
};
}
console.log(
chalk.cyan("On which version of Keycloak do you want to test your theme?")
[
chalk.cyan(
"On which version of Keycloak do you want to test your theme?"
),
chalk.gray(
"You can also explicitly provide the version with `npx keycloakify start-keycloak --keycloak-version 25.0.2` (or any other version)"
)
].join("\n")
);
const { keycloakVersion } = await promptKeycloakVersion({
startingFromMajor: 18,
excludeMajorVersions: [22],
cacheDirPath: buildContext.cacheDirPath
doOmitPatch: true,
buildContext
});
console.log(`${keycloakVersion}`);
return { keycloakVersion };
return { dockerImageTag: keycloakVersion };
})();
const keycloakMajorVersionNumber = SemVer.parse(keycloakVersion).major;
const keycloakMajorVersionNumber = (() => {
if (buildContext.startKeycloakOptions.dockerImage === undefined) {
return SemVer.parse(dockerImageTag).major;
}
const { tag } = buildContext.startKeycloakOptions.dockerImage;
const [wrap] = [18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28]
.map(majorVersionNumber => ({
majorVersionNumber,
index: tag.indexOf(`${majorVersionNumber}`)
}))
.filter(({ index }) => index !== -1)
.sort((a, b) => a.index - b.index);
if (wrap === undefined) {
console.warn(
chalk.yellow(
`Could not determine the major Keycloak version number from the docker image tag ${tag}. Assuming 25`
)
);
return 25;
}
return wrap.majorVersionNumber;
})();
{
const { isAppBuildSuccess } = await appBuild({
@ -150,41 +188,68 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
assert(jarFilePath !== undefined);
console.log(`Using ${chalk.bold(pathBasename(jarFilePath))}`);
const extensionJarFilePaths = await Promise.all(
buildContext.startKeycloakOptions.extensionJars.map(async extensionJar => {
switch (extensionJar.type) {
case "path": {
assert(
await existsAsync(extensionJar.path),
`${extensionJar.path} does not exist`
);
return extensionJar.path;
}
case "url": {
const { archiveFilePath } = await downloadAndExtractArchive({
cacheDirPath: buildContext.cacheDirPath,
fetchOptions: buildContext.fetchOptions,
url: extensionJar.url,
uniqueIdOfOnArchiveFile: "no extraction",
onArchiveFile: async () => {}
});
return archiveFilePath;
}
}
assert<Equals<typeof extensionJar, never>>(false);
})
);
const getRealmJsonFilePath_defaultForKeycloakMajor = (
keycloakMajorVersionNumber: number
) =>
pathJoin(
getThisCodebaseRootDirPath(),
"src",
"bin",
"start-keycloak",
`myrealm-realm-${keycloakMajorVersionNumber}.json`
);
const realmJsonFilePath = await (async () => {
if (cliCommandOptions.realmJsonFilePath !== undefined) {
if (cliCommandOptions.realmJsonFilePath === "none") {
return undefined;
}
console.log(
chalk.green(
`Using realm json file: ${cliCommandOptions.realmJsonFilePath}`
)
);
return getAbsoluteAndInOsFormatPath({
pathIsh: cliCommandOptions.realmJsonFilePath,
cwd: process.cwd()
});
}
if (buildContext.startKeycloakOptions.realmJsonFilePath !== undefined) {
assert(
await existsAsync(buildContext.startKeycloakOptions.realmJsonFilePath),
`${pathRelative(process.cwd(), buildContext.startKeycloakOptions.realmJsonFilePath)} does not exist`
);
return buildContext.startKeycloakOptions.realmJsonFilePath;
}
const internalFilePath = await (async () => {
const dirPath = pathJoin(
getThisCodebaseRootDirPath(),
"src",
"bin",
"start-keycloak"
const defaultFilePath = getRealmJsonFilePath_defaultForKeycloakMajor(
keycloakMajorVersionNumber
);
const filePath = pathJoin(
dirPath,
`myrealm-realm-${keycloakMajorVersionNumber}.json`
);
if (fs.existsSync(filePath)) {
return filePath;
if (fs.existsSync(defaultFilePath)) {
return defaultFilePath;
}
console.log(
@ -195,6 +260,8 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
console.log(chalk.cyan("Select what configuration to use:"));
const dirPath = pathDirname(defaultFilePath);
const { value } = await cliSelect<string>({
values: [
...fs
@ -236,6 +303,40 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
return filePath;
})();
add_test_user_if_missing: {
if (realmJsonFilePath === undefined) {
break add_test_user_if_missing;
}
const realm: Record<string, unknown> = JSON.parse(
fs.readFileSync(realmJsonFilePath).toString("utf8")
);
if (realm.users !== undefined) {
break add_test_user_if_missing;
}
const realmJsonFilePath_internal = (() => {
const filePath = getRealmJsonFilePath_defaultForKeycloakMajor(
keycloakMajorVersionNumber
);
if (!fs.existsSync(filePath)) {
return getRealmJsonFilePath_defaultForKeycloakMajor(25);
}
return filePath;
})();
const users = JSON.parse(
fs.readFileSync(realmJsonFilePath_internal).toString("utf8")
).users;
realm.users = users;
fs.writeFileSync(realmJsonFilePath, JSON.stringify(realm, null, 2), "utf8");
}
async function extractThemeResourcesFromJar() {
await extractArchive({
archiveFilePath: jarFilePath,
@ -274,77 +375,105 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
});
} catch {}
const spawnArgs = [
"docker",
[
"run",
...["-p", `${cliCommandOptions.port}:8080`],
...["--name", CONTAINER_NAME],
...["-e", "KEYCLOAK_ADMIN=admin"],
...["-e", "KEYCLOAK_ADMIN_PASSWORD=admin"],
...(realmJsonFilePath === undefined
? []
: [
"-v",
`${realmJsonFilePath}:/opt/keycloak/data/import/myrealm-realm.json`
]),
...[
"-v",
`${jarFilePath_cacheDir}:/opt/keycloak/providers/keycloak-theme.jar`
],
...(keycloakMajorVersionNumber <= 20
? ["-e", "JAVA_OPTS=-Dkeycloak.profile=preview"]
: []),
...[
...buildContext.themeNames,
...(fs.existsSync(
pathJoin(
buildContext.keycloakifyBuildDirPath,
"theme",
ACCOUNT_V1_THEME_NAME
)
)
? [ACCOUNT_V1_THEME_NAME]
: [])
]
.map(themeName => ({
localDirPath: pathJoin(
buildContext.keycloakifyBuildDirPath,
"theme",
themeName
),
containerDirPath: `/opt/keycloak/themes/${themeName}`
}))
.map(({ localDirPath, containerDirPath }) => [
"-v",
`${localDirPath}:${containerDirPath}:rw`
])
.flat(),
...buildContext.environmentVariables
.map(({ name }) => ({ name, envValue: process.env[name] }))
.map(({ name, envValue }) =>
envValue === undefined ? undefined : { name, envValue }
)
.filter(exclude(undefined))
.map(({ name, envValue }) => [
"--env",
`${name}='${envValue.replace(/'/g, "'\\''")}'`
])
.flat(),
`quay.io/keycloak/keycloak:${keycloakVersion}`,
"start-dev",
...(21 <= keycloakMajorVersionNumber && keycloakMajorVersionNumber < 24
? ["--features=declarative-user-profile"]
: []),
...(realmJsonFilePath === undefined ? [] : ["--import-realm"])
],
{
cwd: buildContext.keycloakifyBuildDirPath,
shell: true
}
] as const;
const DEFAULT_PORT = 8080;
const port =
cliCommandOptions.port ?? buildContext.startKeycloakOptions.port ?? DEFAULT_PORT;
const child = child_process.spawn(...spawnArgs);
const SPACE_PLACEHOLDER = "SPACE_PLACEHOLDER_xKLmdPd";
const dockerRunArgs: string[] = [
`-p${SPACE_PLACEHOLDER}${port}:8080`,
`--name${SPACE_PLACEHOLDER}${CONTAINER_NAME}`,
`-e${SPACE_PLACEHOLDER}KEYCLOAK_ADMIN=admin`,
`-e${SPACE_PLACEHOLDER}KEYCLOAK_ADMIN_PASSWORD=admin`,
...(buildContext.startKeycloakOptions.dockerExtraArgs.length === 0
? []
: [
buildContext.startKeycloakOptions.dockerExtraArgs.join(
SPACE_PLACEHOLDER
)
]),
...(realmJsonFilePath === undefined
? []
: [
`-v${SPACE_PLACEHOLDER}".${pathSep}${pathRelative(process.cwd(), realmJsonFilePath)}":/opt/keycloak/data/import/myrealm-realm.json`
]),
`-v${SPACE_PLACEHOLDER}".${pathSep}${pathRelative(process.cwd(), jarFilePath_cacheDir)}":/opt/keycloak/providers/keycloak-theme.jar`,
...extensionJarFilePaths.map(
jarFilePath =>
`-v${SPACE_PLACEHOLDER}".${pathSep}${pathRelative(process.cwd(), jarFilePath)}":/opt/keycloak/providers/${pathBasename(jarFilePath)}`
),
...(keycloakMajorVersionNumber <= 20
? [`-e${SPACE_PLACEHOLDER}JAVA_OPTS=-Dkeycloak.profile=preview`]
: []),
...[
...buildContext.themeNames,
...(fs.existsSync(
pathJoin(
buildContext.keycloakifyBuildDirPath,
"theme",
ACCOUNT_V1_THEME_NAME
)
)
? [ACCOUNT_V1_THEME_NAME]
: [])
]
.map(themeName => ({
localDirPath: pathJoin(
buildContext.keycloakifyBuildDirPath,
"theme",
themeName
),
containerDirPath: `/opt/keycloak/themes/${themeName}`
}))
.map(
({ localDirPath, containerDirPath }) =>
`-v${SPACE_PLACEHOLDER}".${pathSep}${pathRelative(process.cwd(), localDirPath)}":${containerDirPath}:rw`
),
...buildContext.environmentVariables
.map(({ name }) => ({ name, envValue: process.env[name] }))
.map(({ name, envValue }) =>
envValue === undefined ? undefined : { name, envValue }
)
.filter(exclude(undefined))
.map(
({ name, envValue }) =>
`--env${SPACE_PLACEHOLDER}${name}='${envValue.replace(/'/g, "'\\''")}'`
),
`${buildContext.startKeycloakOptions.dockerImage?.reference ?? "quay.io/keycloak/keycloak"}:${dockerImageTag}`,
"start-dev",
...(21 <= keycloakMajorVersionNumber && keycloakMajorVersionNumber < 24
? ["--features=declarative-user-profile"]
: []),
...(realmJsonFilePath === undefined ? [] : ["--import-realm"]),
...(buildContext.startKeycloakOptions.keycloakExtraArgs.length === 0
? []
: [
buildContext.startKeycloakOptions.keycloakExtraArgs.join(
SPACE_PLACEHOLDER
)
])
];
console.log(
chalk.blue(
[
`$ docker run \\`,
...dockerRunArgs
.map(arg => arg.replace(new RegExp(SPACE_PLACEHOLDER, "g"), " "))
.map(
(line, i, arr) =>
` ${line}${arr.length - 1 === i ? "" : " \\"}`
)
].join("\n")
)
);
const child = child_process.spawn(
"docker",
["run", ...dockerRunArgs.map(line => line.split(SPACE_PLACEHOLDER)).flat()],
{ shell: true }
);
child.stdout.on("data", data => process.stdout.write(data));
@ -355,6 +484,18 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
const srcDirPath = pathJoin(buildContext.projectDirPath, "src");
{
const kcHttpRelativePath = (() => {
const match = buildContext.startKeycloakOptions.dockerExtraArgs
.join(" ")
.match(/KC_HTTP_RELATIVE_PATH=([^ ]+)/);
if (match === null) {
return undefined;
}
return match[1];
})();
const handler = async (data: Buffer) => {
if (!data.toString("utf8").includes("Listening on: http://0.0.0.0:8080")) {
return;
@ -372,7 +513,7 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
)} are mounted in the Keycloak container.`,
"",
`Keycloak Admin console: ${chalk.cyan.bold(
`http://localhost:${cliCommandOptions.port}`
`http://localhost:${port}${kcHttpRelativePath ?? ""}`
)}`,
`- user: ${chalk.cyan.bold("admin")}`,
`- password: ${chalk.cyan.bold("admin")}`,
@ -380,7 +521,21 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
"",
`${chalk.green("Your theme is accessible at:")}`,
`${chalk.green("➜")} ${chalk.cyan.bold(
`https://my-theme.keycloakify.dev${cliCommandOptions.port === 8080 ? "" : `?port=${cliCommandOptions.port}`}`
(() => {
const url = new URL("https://my-theme.keycloakify.dev");
if (port !== DEFAULT_PORT) {
url.searchParams.set("port", `${port}`);
}
if (kcHttpRelativePath !== undefined) {
url.searchParams.set(
"kcHttpRelativePath",
kcHttpRelativePath
);
}
return url.href;
})()
)}`,
"",
"You can login with the following credentials:",

View File

@ -5,9 +5,11 @@ export function assertNoPnpmDlx() {
if (__dirname.includes(`${pathSep}pnpm${pathSep}dlx${pathSep}`)) {
console.log(
[
chalk.red("Please don't use `pnpm dlx keycloakify`"),
chalk.red(
"Please don't use `pnpm dlx keycloakify` (download and execute)"
),
"\nUse `npx keycloakify` or `pnpm exec keycloakify` instead since you want to use the keycloakify",
"version that is installed in your project and not the latest version on NPM."
"version that is installed in your project and not download and run the latest NPM version of keycloakify."
].join(" ")
);
process.exit(1);

View File

@ -4,7 +4,6 @@ import { dirname as pathDirname, join as pathJoin } from "path";
import { assert } from "tsafe/assert";
import { extractArchive } from "./extractArchive";
import { existsAsync } from "./fs.existsAsync";
import * as crypto from "crypto";
import { rm } from "./fs.rm";
@ -21,7 +20,7 @@ export async function downloadAndExtractArchive(params: {
}) => Promise<void>;
cacheDirPath: string;
fetchOptions: FetchOptions | undefined;
}): Promise<{ extractedDirPath: string }> {
}): Promise<{ extractedDirPath: string; archiveFilePath: string }> {
const { url, uniqueIdOfOnArchiveFile, onArchiveFile, cacheDirPath, fetchOptions } =
params;
@ -30,6 +29,8 @@ export async function downloadAndExtractArchive(params: {
const archiveFilePath = pathJoin(cacheDirPath, archiveFileBasename);
download: {
await mkdir(pathDirname(archiveFilePath), { recursive: true });
if (await existsAsync(archiveFilePath)) {
const isDownloaded = await SuccessTracker.getIsDownloaded({
cacheDirPath,
@ -48,8 +49,6 @@ export async function downloadAndExtractArchive(params: {
});
}
await mkdir(pathDirname(archiveFilePath), { recursive: true });
const response = await fetch(url, fetchOptions);
response.body?.setMaxListeners(Number.MAX_VALUE);
@ -136,7 +135,7 @@ export async function downloadAndExtractArchive(params: {
});
}
return { extractedDirPath };
return { extractedDirPath, archiveFilePath };
}
type SuccessTracker = {

View File

@ -1,6 +1,7 @@
import { type FetchOptions } from "make-fetch-happen";
import * as child_process from "child_process";
import * as fs from "fs";
import { exclude } from "tsafe/exclude";
export type ProxyFetchOptions = Pick<
FetchOptions,
@ -23,12 +24,32 @@ export function getProxyFetchOptions(params: {
.split("\n")
.filter(line => !line.startsWith(";"))
.map(line => line.trim())
.map(line => line.split("=", 2) as [string, string])
.map(line => {
const [key, value] = line.split("=");
if (key === undefined) {
return undefined;
}
if (value === undefined) {
return undefined;
}
return [key.trim(), value.trim()] as const;
})
.filter(exclude(undefined))
.filter(([key]) => key !== "")
.map(([key, value]) => {
if (value.startsWith('"') && value.endsWith('"')) {
return [key, value.slice(1, -1)] as const;
}
if (value === "true" || value === "false") {
return [key, value] as const;
}
return undefined;
})
.filter(exclude(undefined))
.reduce(
(
cfg: Record<string, string | string[]>,
[key, value]: [string, string]
) =>
(cfg: Record<string, string | string[]>, [key, value]) =>
key in cfg
? { ...cfg, [key]: [...ensureArray(cfg[key]), value] }
: { ...cfg, [key]: value },
@ -37,18 +58,18 @@ export function getProxyFetchOptions(params: {
})();
const proxy = ensureSingleOrNone(cfg["https-proxy"] ?? cfg["proxy"]);
const noProxy = cfg["noproxy"] ?? cfg["no-proxy"];
function maybeBoolean(arg0: string | undefined) {
return typeof arg0 === "undefined" ? undefined : Boolean(arg0);
}
const strictSSL = ensureSingleOrNone(cfg["strict-ssl"]) === "true";
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") {
if (cafile !== undefined) {
ca.push(
...(() => {
const cafileContent = fs.readFileSync(cafile).toString("utf8");
@ -82,7 +103,7 @@ export function getProxyFetchOptions(params: {
}
function ensureArray<T>(arg0: T | T[]) {
return Array.isArray(arg0) ? arg0 : typeof arg0 === "undefined" ? [] : [arg0];
return Array.isArray(arg0) ? arg0 : arg0 === undefined ? [] : [arg0];
}
function ensureSingleOrNone<T>(arg0: T | T[]) {

View File

@ -40,6 +40,8 @@ const LoginRecoveryAuthnCodeInput = lazy(() => import("keycloakify/login/pages/L
const LoginResetOtp = lazy(() => import("keycloakify/login/pages/LoginResetOtp"));
const LoginX509Info = lazy(() => import("keycloakify/login/pages/LoginX509Info"));
const WebauthnError = lazy(() => import("keycloakify/login/pages/WebauthnError"));
const LoginPasskeysConditionalAuthenticate = lazy(() => import("keycloakify/login/pages/LoginPasskeysConditionalAuthenticate"));
const LoginIdpLinkConfirmOverride = lazy(() => import("keycloakify/login/pages/LoginIdpLinkConfirmOverride"));
type DefaultPageProps = PageProps<KcContext, I18n> & {
UserProfileFormFields: LazyOrNot<(props: UserProfileFormFieldsProps) => JSX.Element>;
@ -121,6 +123,10 @@ export default function DefaultPage(props: DefaultPageProps) {
return <LoginX509Info kcContext={kcContext} {...rest} />;
case "webauthn-error.ftl":
return <WebauthnError kcContext={kcContext} {...rest} />;
case "login-passkeys-conditional-authenticate.ftl":
return <LoginPasskeysConditionalAuthenticate kcContext={kcContext} {...rest} />;
case "login-idp-link-confirm-override.ftl":
return <LoginIdpLinkConfirmOverride kcContext={kcContext} {...rest} />;
}
assert<Equals<typeof kcContext, never>>(false);
})()}

View File

@ -59,7 +59,9 @@ export type KcContext =
| KcContext.LoginRecoveryAuthnCodeInput
| KcContext.LoginResetOtp
| KcContext.LoginX509Info
| KcContext.WebauthnError;
| KcContext.WebauthnError
| KcContext.LoginPasskeysConditionalAuthenticate
| KcContext.LoginIdpLinkConfirmOverride;
assert<KcContext["themeType"] extends ThemeType ? true : false>();
@ -79,8 +81,8 @@ export declare namespace KcContext {
};
realm: {
name: string;
displayName?: string;
displayNameHtml?: string;
displayName: string;
displayNameHtml: string;
internationalizationEnabled: boolean;
registrationEmailAsUsername: boolean;
};
@ -577,6 +579,40 @@ export declare namespace KcContext {
pageId: "webauthn-error.ftl";
isAppInitiatedAction?: boolean;
};
export type LoginPasskeysConditionalAuthenticate = Common & {
pageId: "login-passkeys-conditional-authenticate.ftl";
realm: {
registrationAllowed: boolean;
password: boolean;
};
url: {
registrationUrl: string;
};
registrationDisabled: boolean;
isUserIdentified: boolean | "true" | "false";
challenge: string;
userVerification: string;
rpId: string;
createTimeout: number | string;
authenticators?: {
authenticators: WebauthnAuthenticate.WebauthnAuthenticator[];
};
shouldDisplayAuthenticators?: boolean;
usernameHidden?: boolean;
login: {
username?: string;
};
};
export type LoginIdpLinkConfirmOverride = Common & {
pageId: "login-idp-link-confirm-override.ftl";
url: {
loginRestartFlowUrl: string;
};
idpDisplayName: string;
};
}
export type UserProfile = {

View File

@ -567,6 +567,39 @@ export const kcContextMocks = [
pageId: "webauthn-error.ftl",
...kcContextCommonMock,
isAppInitiatedAction: true
}),
id<KcContext.LoginPasskeysConditionalAuthenticate>({
pageId: "login-passkeys-conditional-authenticate.ftl",
...kcContextCommonMock,
url: {
...kcContextCommonMock.url,
registrationUrl: "#"
},
realm: {
...kcContextCommonMock.realm,
password: true,
registrationAllowed: true
},
registrationDisabled: false,
isUserIdentified: "false",
challenge: "",
userVerification: "not specified",
rpId: "",
createTimeout: "0",
authenticators: {
authenticators: []
},
shouldDisplayAuthenticators: false,
login: {}
}),
id<KcContext.LoginIdpLinkConfirmOverride>({
pageId: "login-idp-link-confirm-override.ftl",
...kcContextCommonMock,
url: {
...kcContextCommonMock.url,
loginRestartFlowUrl: "#"
},
idpDisplayName: "Google"
})
];

View File

@ -224,19 +224,17 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
{auth !== undefined && auth.showTryAnotherWayLink && (
<form id="kc-select-try-another-way-form" action={url.loginAction} method="post">
<div className={kcClsx("kcFormGroupClass")}>
<div className={kcClsx("kcFormGroupClass")}>
<input type="hidden" name="tryAnotherWay" value="on" />
<a
href="#"
id="try-another-way"
onClick={() => {
document.forms["kc-select-try-another-way-form" as never].submit();
return false;
}}
>
{msg("doTryAnotherWay")}
</a>
</div>
<input type="hidden" name="tryAnotherWay" value="on" />
<a
href="#"
id="try-another-way"
onClick={() => {
document.forms["kc-select-try-another-way-form" as never].submit();
return false;
}}
>
{msg("doTryAnotherWay")}
</a>
</div>
</form>
)}

View File

@ -58,7 +58,7 @@ export default function UserProfileFormFields(props: UserProfileFormFieldsProps<
<label htmlFor={attribute.name} className={kcClsx("kcLabelClass")}>
{advancedMsg(attribute.displayName ?? "")}
</label>
{attribute.required && <>*</>}
{attribute.required && <> *</>}
</div>
<div className={kcClsx("kcInputWrapperClass")}>
{attribute.annotations.inputHelperTextBefore !== undefined && (
@ -580,7 +580,7 @@ function TextareaTag(props: InputFieldByTypeProps) {
function SelectTag(props: InputFieldByTypeProps) {
const { attribute, dispatchFormAction, kcClsx, displayableErrors, i18n, valueOrValues } = props;
const { advancedMsg } = i18n;
const { advancedMsgStr } = i18n;
const isMultiple = attribute.annotations.inputType === "multiselect";
@ -649,11 +649,11 @@ function SelectTag(props: InputFieldByTypeProps) {
if (attribute.annotations.inputOptionLabels !== undefined) {
const { inputOptionLabels } = attribute.annotations;
return advancedMsg(inputOptionLabels[option] ?? option);
return advancedMsgStr(inputOptionLabels[option] ?? option);
}
if (attribute.annotations.inputOptionLabelsI18nPrefix !== undefined) {
return advancedMsg(`${attribute.annotations.inputOptionLabelsI18nPrefix}.${option}`);
return advancedMsgStr(`${attribute.annotations.inputOptionLabelsI18nPrefix}.${option}`);
}
return option;

View File

@ -217,7 +217,13 @@ function createI18nTranslationFunctionsFactory<MessageKey_themeDefined extends s
messageWithArgsInjected = messageWithArgsInjected.replace(
new RegExp(`\\{${i + startIndex}\\}`, "g"),
arg.replace(/</g, "&lt;").replace(/>/g, "&gt;")
(() => {
if (key === "loginTitleHtml") {
return arg;
}
return arg.replace(/</g, "&lt;").replace(/>/g, "&gt;");
})()
);
});

View File

@ -59,6 +59,8 @@ export type FormAction =
action: "update";
name: string;
valueOrValues: string | string[];
/** Default false */
displayErrorsImmediately?: boolean;
}
| {
action: "focus lost";
@ -413,6 +415,24 @@ export function useUserProfileForm(params: UseUserProfileFormParams): ReturnType
formFieldStates: state.formFieldStates
});
simulate_focus_lost: {
const { displayErrorsImmediately = false } = formAction;
if (!displayErrorsImmediately) {
break simulate_focus_lost;
}
for (const fieldIndex of formAction.valueOrValues instanceof Array
? formAction.valueOrValues.map((...[, index]) => index)
: [undefined]) {
state = reducer(state, {
action: "focus lost",
name: formAction.name,
fieldIndex
});
}
}
update_password_confirm: {
if (doMakeUserConfirmPassword) {
break update_password_confirm;
@ -425,7 +445,8 @@ export function useUserProfileForm(params: UseUserProfileFormParams): ReturnType
state = reducer(state, {
action: "update",
name: "password-confirm",
valueOrValues: formAction.valueOrValues
valueOrValues: formAction.valueOrValues,
displayErrorsImmediately: formAction.displayErrorsImmediately
});
}
@ -447,7 +468,8 @@ export function useUserProfileForm(params: UseUserProfileFormParams): ReturnType
assert(formFieldState !== undefined);
return formFieldState.valueOrValues;
})()
})(),
displayErrorsImmediately: formAction.displayErrorsImmediately
});
}
@ -1372,14 +1394,10 @@ export function getButtonToDisplayForMultivaluedAttributeField(params: { attribu
})();
if (maxCount === undefined) {
return false;
return true;
}
if (values.length === maxCount) {
return false;
}
return true;
return values.length !== maxCount;
})();
return { hasRemove, hasAdd };

View File

@ -43,7 +43,7 @@ export default function Login(props: PageProps<Extract<KcContext, { pageId: "log
}
socialProvidersNode={
<>
{realm.password && social.providers?.length && (
{realm.password && social.providers !== undefined && social.providers.length !== 0 && (
<div id="kc-social-providers" className={kcClsx("kcFormSocialAccountSectionClass")}>
<hr />
<h2>{msg("identity-provider-login-label")}</h2>

View File

@ -0,0 +1,40 @@
import { getKcClsx } from "keycloakify/login/lib/kcClsx";
import type { PageProps } from "keycloakify/login/pages/PageProps";
import type { KcContext } from "../KcContext";
import type { I18n } from "../i18n";
// NOTE: Added with Keycloak 25
export default function LoginIdpLinkConfirmOverride(props: PageProps<Extract<KcContext, { pageId: "login-idp-link-confirm-override.ftl" }>, I18n>) {
const { kcContext, i18n, doUseDefaultCss, Template, classes } = props;
const { kcClsx } = getKcClsx({
doUseDefaultCss,
classes
});
const { url, idpDisplayName } = kcContext;
const { msg } = i18n;
return (
<Template kcContext={kcContext} i18n={i18n} doUseDefaultCss={doUseDefaultCss} classes={classes} headerNode={msg("confirmOverrideIdpTitle")}>
<form id="kc-register-form" action={url.loginAction} method="post">
{msg("pageExpiredMsg1")}{" "}
<a id="loginRestartLink" href={url.loginRestartFlowUrl}>
{msg("doClickHere")}
</a>
<br />
<br />
<button
type="submit"
className={kcClsx("kcButtonClass", "kcButtonDefaultClass", "kcButtonBlockClass", "kcButtonLargeClass")}
name="submitAction"
id="confirmOverride"
value="confirmOverride"
>
{msg("confirmOverrideIdpContinue", idpDisplayName)}
</button>
</form>
</Template>
);
}

View File

@ -0,0 +1,252 @@
import { useEffect, Fragment } from "react";
import { clsx } from "keycloakify/tools/clsx";
import type { PageProps } from "keycloakify/login/pages/PageProps";
import { useInsertScriptTags } from "keycloakify/tools/useInsertScriptTags";
import { getKcClsx } from "keycloakify/login/lib/kcClsx";
import { assert } from "keycloakify/tools/assert";
import type { KcContext } from "../KcContext";
import type { I18n } from "../i18n";
// NOTE: From Keycloak 25.0.4
export default function LoginPasskeysConditionalAuthenticate(
props: PageProps<Extract<KcContext, { pageId: "login-passkeys-conditional-authenticate.ftl" }>, I18n>
) {
const { kcContext, i18n, doUseDefaultCss, Template, classes } = props;
const {
messagesPerField,
login,
url,
usernameHidden,
shouldDisplayAuthenticators,
authenticators,
registrationDisabled,
realm,
isUserIdentified,
challenge,
userVerification,
rpId,
createTimeout
} = kcContext;
const { msg, msgStr, advancedMsg } = i18n;
const { kcClsx } = getKcClsx({
doUseDefaultCss,
classes
});
const { insertScriptTags } = useInsertScriptTags({
componentOrHookName: "LoginRecoveryAuthnCodeConfig",
scriptTags: [
{
type: "module",
textContent: `
import { authenticateByWebAuthn } from "${url.resourcesPath}/js/webauthnAuthenticate.js";
import { initAuthenticate } from "${url.resourcesPath}/js/passkeysConditionalAuth.js";
const authButton = document.getElementById('authenticateWebAuthnButton');
const input = {
isUserIdentified : ${isUserIdentified},
challenge : '${challenge}',
userVerification : '${userVerification}',
rpId : '${rpId}',
createTimeout : ${createTimeout},
errmsg : "${msgStr("webauthn-unsupported-browser-text")}"
};
authButton.addEventListener("click", () => {
authenticateByWebAuthn(input);
});
const args = {
isUserIdentified : ${isUserIdentified},
challenge : '${challenge}',
userVerification : '${userVerification}',
rpId : '${rpId}',
createTimeout : ${createTimeout},
errmsg : "${msgStr("passkey-unsupported-browser-text")}"
};
document.addEventListener("DOMContentLoaded", (event) => initAuthenticate(args));
`
}
]
});
useEffect(() => {
insertScriptTags();
}, []);
return (
<Template
kcContext={kcContext}
i18n={i18n}
doUseDefaultCss={doUseDefaultCss}
classes={classes}
headerNode={msg("passkey-login-title")}
infoNode={
realm.registrationAllowed &&
!registrationDisabled && (
<div id="kc-registration">
<span>
${msg("noAccount")}{" "}
<a tabIndex={6} href={url.registrationUrl}>
{msg("doRegister")}
</a>
</span>
</div>
)
}
>
<form id="webauth" action={url.loginAction} method="post">
<input type="hidden" id="clientDataJSON" name="clientDataJSON" />
<input type="hidden" id="authenticatorData" name="authenticatorData" />
<input type="hidden" id="signature" name="signature" />
<input type="hidden" id="credentialId" name="credentialId" />
<input type="hidden" id="userHandle" name="userHandle" />
<input type="hidden" id="error" name="error" />
</form>
<div className={kcClsx("kcFormGroupClass")} no-bottom-margin="true" style={{ marginBottom: 0 }}>
{authenticators !== undefined && Object.keys(authenticators).length !== 0 && (
<>
<form id="authn_select" className={kcClsx("kcFormClass")}>
{authenticators.authenticators.map((authenticator, i) => (
<input key={i} type="hidden" name="authn_use_chk" readOnly value={authenticator.credentialId} />
))}
</form>
{shouldDisplayAuthenticators && (
<>
{authenticators.authenticators.length > 1 && (
<p className={kcClsx("kcSelectAuthListItemTitle")}>{msg("passkey-available-authenticators")}</p>
)}
<div className={kcClsx("kcFormClass")}>
{authenticators.authenticators.map((authenticator, i) => (
<div key={i} id={`kc-webauthn-authenticator-item-${i}`} className={kcClsx("kcSelectAuthListItemClass")}>
<i
className={clsx(
(() => {
const className = kcClsx(authenticator.transports.iconClass as any);
if (className === authenticator.transports.iconClass) {
return kcClsx("kcWebAuthnDefaultIcon");
}
return className;
})(),
kcClsx("kcSelectAuthListItemIconPropertyClass")
)}
/>
<div className={kcClsx("kcSelectAuthListItemBodyClass")}>
<div
id={`kc-webauthn-authenticator-label-${i}`}
className={kcClsx("kcSelectAuthListItemHeadingClass")}
>
{advancedMsg(authenticator.label)}
</div>
{authenticator.transports !== undefined &&
authenticator.transports.displayNameProperties !== undefined &&
authenticator.transports.displayNameProperties.length !== 0 && (
<div
id={`kc-webauthn-authenticator-transport-${i}`}
className={kcClsx("kcSelectAuthListItemDescriptionClass")}
>
{authenticator.transports.displayNameProperties.map((nameProperty, i, arr) => (
<Fragment key={i}>
<span key={i}> {advancedMsg(nameProperty)} </span>
{i !== arr.length - 1 && <span>, </span>}
</Fragment>
))}
</div>
)}
<div className={kcClsx("kcSelectAuthListItemDescriptionClass")}>
<span id={`kc-webauthn-authenticator-createdlabel-${i}`}>{msg("passkey-createdAt-label")}</span>
<span id={`kc-webauthn-authenticator-created-${i}`}>{authenticator.createdAt}</span>
</div>
</div>
<div className={kcClsx("kcSelectAuthListItemFillClass")} />
</div>
))}
</div>
</>
)}
<div id="kc-form">
<div id="kc-form-wrapper">
{realm.password && (
<form
id="kc-form-passkey"
action={url.loginAction}
method="post"
style={{ display: "none" }}
onSubmit={event => {
try {
// @ts-expect-error
event.target.login.disabled = true;
} catch {}
return true;
}}
>
{!usernameHidden && (
<div className={kcClsx("kcFormGroupClass")}>
<label htmlFor="username" className={kcClsx("kcLabelClass")}>
{msg("passkey-autofill-select")}
</label>
<input
tabIndex={1}
id="username"
aria-invalid={messagesPerField.existsError("username")}
className={kcClsx("kcInputClass")}
name="username"
defaultValue={login.username ?? ""}
//autoComplete="username webauthn"
type="text"
autoFocus
autoComplete="off"
/>
{messagesPerField.existsError("username") && (
<span id="input-error-username" className={kcClsx("kcInputErrorMessageClass")} aria-live="polite">
{messagesPerField.get("username")}
</span>
)}
</div>
)}
</form>
)}
<div id="kc-form-passkey-button" className={kcClsx("kcFormButtonsClass")} style={{ display: "none" }}>
<input
id="authenticateWebAuthnButton"
type="button"
onClick={() => {
assert("doAuthenticate" in window);
assert(typeof window.doAuthenticate === "function");
window.doAuthenticate(
[],
rpId,
challenge,
typeof isUserIdentified === "boolean" ? isUserIdentified : isUserIdentified === "true",
createTimeout,
userVerification,
msgStr("passkey-unsupported-browser-text")
);
}}
autoFocus
value={msgStr("passkey-doAuthenticate")}
className={kcClsx("kcButtonClass", "kcButtonPrimaryClass", "kcButtonBlockClass", "kcButtonLargeClass")}
/>
</div>
<div id="kc-form-passkey-button" className={kcClsx("kcFormButtonsClass")} style={{ display: "none" }}>
<input
id="authenticateWebAuthnButton"
type="button"
autoFocus
value={msgStr("passkey-doAuthenticate")}
className={kcClsx("kcButtonClass", "kcButtonPrimaryClass", "kcButtonBlockClass", "kcButtonLargeClass")}
/>
</div>
</div>
</div>
</>
)}
</div>
</Template>
);
}

View File

@ -40,7 +40,7 @@ export default function LoginUsername(props: PageProps<Extract<KcContext, { page
headerNode={msg("doLogIn")}
socialProvidersNode={
<>
{realm.password && social.providers?.length && (
{realm.password && social.providers !== undefined && social.providers.length !== 0 && (
<div id="kc-social-providers" className={kcClsx("kcFormSocialAccountSectionClass")}>
<hr />
<h2>{msg("identity-provider-login-label")}</h2>

View File

@ -153,7 +153,7 @@ export default function WebauthnRegister(props: PageProps<Extract<KcContext, { p
function getPubKeyCredParams(signatureAlgorithmsList) {
let pubKeyCredParams = [];
if (signatureAlgorithmsList === []) {
if (signatureAlgorithmsList.length === 0) {
pubKeyCredParams.push({type: "public-key", alg: -7});
return pubKeyCredParams;
}
@ -184,7 +184,7 @@ export default function WebauthnRegister(props: PageProps<Extract<KcContext, { p
}
function getTransportsAsString(transportsList) {
if (transportsList === '' || transportsList.constructor !== Array) return "";
if (transportsList === '' || Array.isArray(transportsList)) return "";
let transportsString = "";

View File

@ -22,7 +22,15 @@ export function useInsertLinkTags(params: {
alreadyMountedComponentOrHookNames.has(componentOrHookName);
if (isAlreadyMounted) {
window.location.reload();
reload: {
if (
new URL(window.location.href).searchParams.get("viewMode") === "docs"
) {
// NOTE: Special case for Storybook, we want to avoid infinite reload loop.
break reload;
}
window.location.reload();
}
return;
}

View File

@ -40,7 +40,15 @@ export function useInsertScriptTags(params: {
alreadyMountedComponentOrHookNames.has(componentOrHookName);
if (isAlreadyMounted) {
window.location.reload();
reload: {
if (
new URL(window.location.href).searchParams.get("viewMode") === "docs"
) {
// NOTE: Special case for Storybook, we want to avoid infinite reload loop.
break reload;
}
window.location.reload();
}
return;
}

View File

@ -0,0 +1,94 @@
import React from "react";
import type { Meta, StoryObj } from "@storybook/react";
import { useInsertLinkTags } from "../../dist/tools/useInsertLinkTags";
import { tss } from "../tss";
// @ts-expect-error
import screenshotPngUrl from "./screenshot.png";
const meta = {
title: "Account SPA/index.ftl"
} satisfies Meta<any>;
export default meta;
type Story = StoryObj<typeof meta>;
export const NotInStorybookButSupported: Story = {
render: () => <AccountSpa />
};
function AccountSpa() {
console.log(window.location.href);
useInsertLinkTags({
componentOrHookName: "Template",
hrefs: []
});
const { classes, theme } = useStyles();
return (
<div className={classes.root}>
<div className={classes.content}>
<p>
Keycloakify offers two option for creating an account theme:{" "}
<a href="https://docs.keycloakify.dev/account-theme#multi-page" target="_blank">
Multi Page
</a>{" "}
or{" "}
<a href="https://docs.keycloakify.dev/account-theme#single-page" target="_blank">
Single Page
</a>
. Since the account Single Page does not support Storybook, here is a screenshot of it's default look:
<br />
<br />
<img className={classes.screenshot} alt="image" src={screenshotPngUrl} />
<br />
<a href="https://docs.keycloakify.dev/account-theme" target="_blank">
Learn more
</a>
</p>
</div>
</div>
);
}
const useStyles = tss.withName({ AccountSpa }).create(({ isDark, theme }) => ({
root: {
height: "100vh",
color: isDark ? "white" : "black",
backgroundColor: theme.appContentBg,
fontFamily: "'Work Sans'",
fontSize: "14px",
lineHeight: "24px",
WebkitFontSmoothing: "antialiased",
"& a": {
color: theme.colorSecondary,
textDecoration: "none",
"&:hover": {
textDecoration: "underline"
}
},
"& h1": {
fontSize: "32px",
marginBottom: 35
},
display: "flex",
justifyContent: "center"
},
content: {
maxWidth: 750,
textAlign: "center",
marginTop: 100
},
keycloakifyLogoWrapper: {
display: "flex",
justifyContent: "center"
},
keycloakifyLogo: {
width: 400
},
screenshot: {
maxWidth: "100%"
}
}));

Binary file not shown.

After

Width:  |  Height:  |  Size: 269 KiB

View File

@ -15,6 +15,7 @@ export default meta;
type Story = StoryObj<typeof meta>;
// NOTE: Enable in your Keycloak realm with: https://github.com/user-attachments/assets/5fc5e49e-a172-4cb0-897a-49baac284b47
export const Default: Story = {
render: () => (
<KcPageStory

View File

@ -2,18 +2,20 @@ import React from "react";
import { memo, useState } from "react";
import { useConstCallback } from "powerhooks";
import { keyframes } from "tss-react";
// @ts-expect-error
import keycloakifyLogoHeroMovingPngUrl from "./keycloakify-logo-hero-moving.png";
// @ts-expect-error
import keycloakifyLogoHeroStillPngUrl from "./keycloakify-logo-hero-still.png";
import { makeStyles } from "./tss";
import { tss } from "../tss";
export type Props = {
style?: React.CSSProperties;
className?: string;
id?: string;
onLoad?: () => void;
};
export const KeycloakifyRotatingLogo = memo((props: Props) => {
const { id, style, onLoad: onLoadProp } = props;
const { id, className, onLoad: onLoadProp } = props;
const [isImageLoaded, setIsImageLoaded] = useState(false);
@ -22,40 +24,41 @@ export const KeycloakifyRotatingLogo = memo((props: Props) => {
onLoadProp?.();
});
const { classes } = useStyles({
const { cx, classes } = useStyles({
isImageLoaded
});
return (
<div id={id} className={classes.root} style={style}>
<div id={id} className={cx(classes.root, className)}>
<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
}
}));
const useStyles = tss
.withParams<{ isImageLoaded: boolean }>()
.withName({ KeycloakifyRotatingLogo })
.create(({ 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
}
}));

View File

@ -1,31 +0,0 @@
import { Meta } from "@storybook/addon-docs";
import { KeycloakifyRotatingLogo } from "./KeycloakifyRotatingLogo";
<Meta
title="Introduction"
parameters={{
"viewMode": "docs",
"previewTabs": {
"canvas": { "hidden": true },
"zoom": { "hidden": true },
"storybook/background": { "hidden": true },
"storybook/viewport": { "hidden": true },
},
}}
/>
<div style={{ "margin": "0 auto", "maxWidth": "700px", "textAlign": "center" }}>
<div style={{ "display": "flex", "justifyContent": "center" }}>
<KeycloakifyRotatingLogo style={{ "width": 400 }} />
</div>
<h1><a href="#">Keycloakify </a> Storybook</h1>
<p>
This website showcases all the Keycloak user-facing pages of the login and account theme.
The storybook serves as a reference to help you determine which pages you would like to personalize.
These pages are a direct React adaptation of the [built-in FreeMarker Keycloak pages](https://github.com/keycloak/keycloak/tree/24.0.4/themes/src/main/resources/theme/base).
You may notice some visual bugs on certain pages; these issues were not introduced by Keycloakiy and are also present in the default Keycloak 24 theme.
</p>
</div>

View File

@ -0,0 +1,90 @@
import React from "react";
import type { Meta, StoryObj } from "@storybook/react";
import { KeycloakifyRotatingLogo } from "./KeycloakifyRotatingLogo";
import { useInsertLinkTags } from "../../dist/tools/useInsertLinkTags";
import { useOnFistMount } from "../../dist/tools/useOnFirstMount";
import { tss } from "../tss";
const meta = {
title: "Introduction"
} satisfies Meta<any>;
export default meta;
type Story = StoryObj<typeof meta>;
export const WhatIsThisWebsite: Story = {
render: () => <Introduction />
};
function Introduction() {
console.log(window.location.href);
useInsertLinkTags({
componentOrHookName: "Template",
hrefs: []
});
const { classes, theme } = useStyles();
return (
<div className={classes.root}>
<div className={classes.content}>
<div className={classes.keycloakifyLogoWrapper}>
<KeycloakifyRotatingLogo className={classes.keycloakifyLogo} />
</div>
<h1>
<a href={theme.brandUrl}>Keycloakify </a> Storybook
</h1>
<p>
This website showcases all the Keycloak user-facing pages of the Login and{" "}
<a href="https://docs.keycloakify.dev/account-theme#multi-page">Account Multi-Page theme</a>.<br />
The storybook serves as a reference to help you determine which pages you would like to personalize.
<br />
These pages are a direct React adaptation of the{" "}
<a href="https://github.com/keycloak/keycloak/tree/24.0.4/themes/src/main/resources/theme/base" target="_blank">
built-in FreeMarker Keycloak pages
</a>
.
</p>
</div>
</div>
);
}
const useStyles = tss.withName({ Introduction }).create(({ isDark, theme }) => ({
root: {
height: "100vh",
color: isDark ? "white" : "black",
backgroundColor: theme.appContentBg,
fontFamily: "'Work Sans'",
fontSize: "14px",
lineHeight: "24px",
WebkitFontSmoothing: "antialiased",
"& a": {
color: theme.colorSecondary,
textDecoration: "none",
"&:hover": {
textDecoration: "underline"
}
},
"& h1": {
fontSize: "32px",
marginBottom: 35
},
display: "flex",
justifyContent: "center"
},
content: {
maxWidth: 750,
textAlign: "center"
},
keycloakifyLogoWrapper: {
display: "flex",
justifyContent: "center"
},
keycloakifyLogo: {
width: 400
}
}));

View File

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

View File

@ -122,73 +122,85 @@ export const WithSocialProviders: Story = {
loginUrl: "google",
alias: "google",
providerId: "google",
displayName: "Google"
displayName: "Google",
iconClasses: "fa fa-google"
},
{
loginUrl: "microsoft",
alias: "microsoft",
providerId: "microsoft",
displayName: "Microsoft"
displayName: "Microsoft",
iconClasses: "fa fa-windows"
},
{
loginUrl: "facebook",
alias: "facebook",
providerId: "facebook",
displayName: "Facebook"
displayName: "Facebook",
iconClasses: "fa fa-facebook"
},
{
loginUrl: "instagram",
alias: "instagram",
providerId: "instagram",
displayName: "Instagram"
displayName: "Instagram",
iconClasses: "fa fa-instagram"
},
{
loginUrl: "twitter",
alias: "twitter",
providerId: "twitter",
displayName: "Twitter"
displayName: "Twitter",
iconClasses: "fa fa-twitter"
},
{
loginUrl: "linkedin",
alias: "linkedin",
providerId: "linkedin",
displayName: "LinkedIn"
displayName: "LinkedIn",
iconClasses: "fa fa-linkedin"
},
{
loginUrl: "stackoverflow",
alias: "stackoverflow",
providerId: "stackoverflow",
displayName: "Stackoverflow"
displayName: "Stackoverflow",
iconClasses: "fa fa-stack-overflow"
},
{
loginUrl: "github",
alias: "github",
providerId: "github",
displayName: "Github"
displayName: "Github",
iconClasses: "fa fa-github"
},
{
loginUrl: "gitlab",
alias: "gitlab",
providerId: "gitlab",
displayName: "Gitlab"
displayName: "Gitlab",
iconClasses: "fa fa-gitlab"
},
{
loginUrl: "bitbucket",
alias: "bitbucket",
providerId: "bitbucket",
displayName: "Bitbucket"
displayName: "Bitbucket",
iconClasses: "fa fa-bitbucket"
},
{
loginUrl: "paypal",
alias: "paypal",
providerId: "paypal",
displayName: "PayPal"
displayName: "PayPal",
iconClasses: "fa fa-paypal"
},
{
loginUrl: "openshift",
alias: "openshift",
providerId: "openshift",
displayName: "OpenShift"
displayName: "OpenShift",
iconClasses: "fa fa-cloud"
}
]
}

View File

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

View File

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

13
stories/tss.ts Normal file
View File

@ -0,0 +1,13 @@
import { createTss } from "tss-react";
import { useDarkMode } from "storybook-dark-mode";
import { darkTheme, lightTheme } from "../.storybook/customTheme";
function useContext() {
const isDark = useDarkMode();
return { isDark, theme: isDark ? darkTheme : lightTheme };
}
export const { tss } = createTss({
useContext
});

947
yarn.lock

File diff suppressed because it is too large Load Diff