Compare commits
143 Commits
v7.9.1
...
v7.16.0-rc
Author | SHA1 | Date | |
---|---|---|---|
721d654cb8 | |||
dfbb3886e7 | |||
3bb0377950 | |||
5156b2e0cc | |||
6b81cf4a24 | |||
cca3a68fe4 | |||
adb2904872 | |||
d68b8d03dd | |||
e7afb88f22 | |||
48cbfc64c0 | |||
0b067858bc | |||
2d44d98f17 | |||
74ef3096ae | |||
8f1163fd75 | |||
a240d503c5 | |||
e331a641b2 | |||
85db4b8e0a | |||
0aa139cf4a | |||
4140ca6fbd | |||
a8ce9da9ee | |||
476a33c0ab | |||
8e868c9fda | |||
17c8b1a172 | |||
b374c04d73 | |||
e750d824ad | |||
dd4c50c3eb | |||
20cc869299 | |||
7214dbccdb | |||
e6cebdd546 | |||
0301003ccf | |||
de2efe0c01 | |||
90d765d7f6 | |||
3e0a1721ce | |||
7214fbcd4c | |||
4b8aecfe91 | |||
387c71c0aa | |||
8d5ce21df4 | |||
f6dfcfbae9 | |||
69e9595db9 | |||
de390678fd | |||
cf9a7b8c60 | |||
73e9c16a8d | |||
9775623981 | |||
20b7bb3c99 | |||
3defc16658 | |||
0dbe592182 | |||
a7c0e5bdaa | |||
3b051cbbea | |||
f7edfd1c29 | |||
b182c43965 | |||
4639e7ad2e | |||
56241203a0 | |||
8c8540de5d | |||
b45af78322 | |||
98bcf3bf7e | |||
e28bcfced3 | |||
a5bd990245 | |||
58301e0844 | |||
c9213fb6cd | |||
641819a364 | |||
3ee3a8b41d | |||
5600403088 | |||
3b00bace23 | |||
fcba470aad | |||
206e602d73 | |||
f98d1aaade | |||
310f857257 | |||
a2b1055094 | |||
f23ddecef3 | |||
54687ec3c0 | |||
545f0fcea5 | |||
5db8ce3043 | |||
ed48669ae1 | |||
69c3befb2d | |||
fc39e837ea | |||
6df9f28c02 | |||
f3d0947427 | |||
3326a4cf2a | |||
9a6ea87b0c | |||
12179d0ec0 | |||
d4141fc51e | |||
c32ab6181c | |||
3847882599 | |||
4db157f663 | |||
351b4e84c9 | |||
0c65561bcb | |||
00200f75a0 | |||
58614a74f5 | |||
f3d64663a0 | |||
8be8c270f8 | |||
a56037f1c9 | |||
2ff7955ec3 | |||
f2044c4d26 | |||
4113f0faea | |||
bacd09484a | |||
8253eb62bd | |||
70b659a0a0 | |||
79ed74ab17 | |||
93bb3ebd69 | |||
e8e516159c | |||
1431c031a0 | |||
209c2183e1 | |||
0c98c282a0 | |||
58c10796a1 | |||
603e6a99f3 | |||
6622ebc04e | |||
465dbb4a8d | |||
08ae908453 | |||
c35a1e7c50 | |||
ecb22c3829 | |||
eebf969f7e | |||
5816f25c3e | |||
b2a81d880d | |||
b10c1476a6 | |||
e11cd09a12 | |||
27575eda68 | |||
f33b9a1ec6 | |||
7c45fff7ba | |||
ecdb0775cd | |||
6ef90a56ed | |||
71b86ff43b | |||
0535e06ae1 | |||
6261f5e7cc | |||
f256b74929 | |||
4f1182a230 | |||
e7c20547f8 | |||
9ab4c510fe | |||
7d78c52064 | |||
6223d91291 | |||
840b5e1312 | |||
e69813f6e3 | |||
3c0c057e06 | |||
984d12b3f2 | |||
61dc54f115 | |||
34e47cccc1 | |||
c170345550 | |||
1e40706f72 | |||
ea1a747ebf | |||
a14e967020 | |||
0fff10d2c6 | |||
7c2123614d | |||
d149866703 | |||
18039140db |
@ -140,6 +140,24 @@
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "satanshiro",
|
||||
"name": "satanshiro",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/38865738?v=4",
|
||||
"profile": "https://github.com/satanshiro",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "kpoelhekke",
|
||||
"name": "Koen Poelhekke",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/1632377?v=4",
|
||||
"profile": "https://poelhekke.dev",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
}
|
||||
],
|
||||
"contributorsPerLine": 7,
|
||||
|
1
.github/workflows/ci.yaml
vendored
1
.github/workflows/ci.yaml
vendored
@ -74,7 +74,6 @@ jobs:
|
||||
id: step1
|
||||
with:
|
||||
action_name: is_package_json_version_upgraded
|
||||
branch: ${{ github.head_ref || github.ref }}
|
||||
|
||||
create_github_release:
|
||||
runs-on: ubuntu-latest
|
||||
|
@ -5,7 +5,7 @@ import { useDarkMode } from "storybook-dark-mode";
|
||||
import { darkTheme, lightTheme } from "./customTheme";
|
||||
import "./static/fonts/WorkSans/font.css";
|
||||
|
||||
export const DocsContainer = ({ children, context }) => {
|
||||
export function DocsContainer({ children, context }) {
|
||||
const isStorybookUiDark = useDarkMode();
|
||||
|
||||
const theme = isStorybookUiDark ? darkTheme : lightTheme;
|
||||
@ -58,4 +58,19 @@ export const DocsContainer = ({ children, context }) => {
|
||||
</BaseContainer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export function CanvasContainer({ children }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{`
|
||||
body {
|
||||
padding: 0 !important;
|
||||
}
|
||||
`}</style>
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
|
||||
}
|
@ -1,5 +1,5 @@
|
||||
import { darkTheme, lightTheme } from "./customTheme";
|
||||
import { DocsContainer } from "./DocsContainer";
|
||||
import { DocsContainer, CanvasContainer } from "./Containers";
|
||||
|
||||
export const parameters = {
|
||||
"actions": { "argTypesRegex": "^on[A-Z].*" },
|
||||
@ -103,11 +103,22 @@ export const parameters = {
|
||||
},
|
||||
};
|
||||
|
||||
export const decorators = [
|
||||
(Story) => (
|
||||
<CanvasContainer>
|
||||
<Story />
|
||||
</CanvasContainer>
|
||||
),
|
||||
];
|
||||
|
||||
const { getHardCodedWeight } = (() => {
|
||||
|
||||
const orderedPagesPrefix = [
|
||||
"Introduction",
|
||||
"login/login.ftl",
|
||||
"login/register-user-profile.ftl",
|
||||
"login/register.ftl",
|
||||
"login/terms.ftl",
|
||||
"login/error.ftl",
|
||||
];
|
||||
|
||||
|
@ -1 +1 @@
|
||||
react-dsfr-components.etalab.studio
|
||||
storybook.keycloakify.dev
|
75
README.md
75
README.md
@ -20,7 +20,7 @@
|
||||
<a href="https://github.com/thomasdarimont/awesome-keycloak">
|
||||
<img src="https://awesome.re/mentioned-badge.svg"/>
|
||||
</a>
|
||||
<a href="https://discord.gg/rBzsYtUn">
|
||||
<a href="https://discord.gg/kYFZG7fQmn">
|
||||
<img src="https://img.shields.io/discord/1097708346976505977"/>
|
||||
</a>
|
||||
<p align="center">
|
||||
@ -28,38 +28,60 @@
|
||||
-
|
||||
<a href="https://docs.keycloakify.dev">Documentation</a>
|
||||
-
|
||||
<a href="https://storybook.keycloakify.dev/storybook">Storybook</a>
|
||||
<a href="https://storybook.keycloakify.dev">Storybook</a>
|
||||
-
|
||||
<a href="https://github.com/codegouvfr/keycloakify-starter">Starter project</a>
|
||||
</p>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<i>Ultimately this build tool generates a Keycloak theme <a href="https://www.keycloakify.dev">Learn more</a></i>
|
||||
<i>This build tool generates a Keycloak theme <a href="https://www.keycloakify.dev">Learn more</a></i>
|
||||
<img src="https://user-images.githubusercontent.com/6702424/110260457-a1c3d380-7fac-11eb-853a-80459b65626b.png">
|
||||
</p>
|
||||
|
||||
> Whether or not React is your preferred framework, Keycloakify
|
||||
> offers a solid option for building Keycloak themes.
|
||||
> It's not just a convenient way to create a Keycloak theme
|
||||
> when using React; it's a well-regarded solution that many
|
||||
> developers appreciate.
|
||||
|
||||
> 📣 🛑 Account themes generated by Keycloakify are currently not compatible with Keycloak 22.
|
||||
> We are working on a solution. [More info](https://github.com/keycloakify/keycloakify/issues/389#issuecomment-1661591906).
|
||||
> Note that login and email themes are not affected.
|
||||
|
||||
Keycloakify is fully compatible with Keycloak, starting from version 11 and is anticipated to maintain compatibility with all future versions.
|
||||
You can update your Keycloak, your Keycloakify generated theme won't break.
|
||||
To understand the basis of my confidence in this, you can [visit this discussion thread where I've explained in detail](https://github.com/keycloakify/keycloakify/discussions/346#discussioncomment-5889791).
|
||||
|
||||
## Sponsor 👼
|
||||
|
||||
We are exclusively sponsored by [Cloud IAM](https://www.cloud-iam.com), a French company offering Keycloak as a service.
|
||||
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.
|
||||
|
||||
[Cloud IAM](https://www.cloud-iam.com/) provides the following services:
|
||||
[Cloud IAM](https://cloud-iam.com/?mtm_campaign=keycloakify-deal&mtm_source=keycloakify-github) provides the following services:
|
||||
|
||||
- Perfectly configured and optimized Keycloak IAM, ready in seconds.
|
||||
- Simplify and secure your Keycloak Identity and Access Management. Keycloak as a Service.
|
||||
- Custom theme building for your brand using Keycloakify.
|
||||
|
||||
<div align="center">
|
||||
|
||||

|
||||
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
|
||||

|
||||
|
||||
</div>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.cloud-iam.com/">
|
||||
<img src="https://user-images.githubusercontent.com/6702424/232165752-17134e68-4a55-4d6e-8672-e9132ecac5d5.svg" alt="Cloud IAM Logo" width="200"/>
|
||||
</a>
|
||||
<br/>
|
||||
<i>Use promo code <code>keycloakify5</code> </i>
|
||||
<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>
|
||||
</p>
|
||||
|
||||
Thank you, [Cloud IAM](https://www.cloud-iam.com/), for your support!
|
||||
Thank you, [Cloud IAM](https://cloud-iam.com/?mtm_campaign=keycloakify-deal&mtm_source=keycloakify-github), for your support!
|
||||
|
||||
## Contributors ✨
|
||||
|
||||
@ -90,6 +112,8 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://www.gravitysoftware.be"><img src="https://avatars.githubusercontent.com/u/1140574?v=4?s=100" width="100px;" alt="Thomas Silvestre"/><br /><sub><b>Thomas Silvestre</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=thosil" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/satanshiro"><img src="https://avatars.githubusercontent.com/u/38865738?v=4?s=100" width="100px;" alt="satanshiro"/><br /><sub><b>satanshiro</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=satanshiro" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://poelhekke.dev"><img src="https://avatars.githubusercontent.com/u/1632377?v=4?s=100" width="100px;" alt="Koen Poelhekke"/><br /><sub><b>Koen Poelhekke</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=kpoelhekke" title="Code">💻</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@ -101,6 +125,28 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
|
||||
|
||||
# Changelog highlights
|
||||
|
||||
## 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.extrathemenames).
|
||||
|
||||
## 7.9
|
||||
|
||||
- Separate script for copying the default theme static assets to the public directory.
|
||||
@ -108,10 +154,9 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
|
||||
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.
|
||||
[See the updated starter](https://github.com/keycloakify/keycloakify-starter/blob/94532fcf10bf8b19e0873be8575fd28a8958a806/package.json#L11). `public/keycloak-assets` shouldn't be tracked by GIT and is automatically ignored.
|
||||
|
||||
## 7.7
|
||||
## 7.7
|
||||
|
||||
- Better storybook support, see [the starter project](https://github.com/keycloakify/keycloakify-starter).
|
||||
|
||||
|
@ -30,18 +30,6 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"extraLoginPages": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"extraAccountPages": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"extraThemeProperties": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
@ -70,12 +58,6 @@
|
||||
"keycloakifyBuildDirPath": {
|
||||
"type": "string"
|
||||
},
|
||||
"customUserAttributes": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"themeName": {
|
||||
"type": "string"
|
||||
}
|
||||
|
15
package.json
15
package.json
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "keycloakify",
|
||||
"version": "7.9.1",
|
||||
"version": "7.16.0-rc.0",
|
||||
"description": "Create Keycloak themes using React",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@ -10,11 +10,10 @@
|
||||
"types": "dist/index.d.ts",
|
||||
"scripts": {
|
||||
"prepare": "yarn generate-i18n-messages",
|
||||
"build": "rimraf dist/ && tsc -p src/bin && tsc -p src && tsc-alias -p src/tsconfig.json && yarn grant-exec-perms && yarn copy-files dist/",
|
||||
"watch-in-starter": "yarn build && yarn link-in-starter && (concurrently \"tsc -p src -w\" \"tsc-alias -p src/tsconfig.json\" \"tsc -p src/bin -w\")",
|
||||
"build": "rimraf dist/ && tsc -p src/bin && tsc -p src && tsc-alias -p src/tsconfig.json && yarn grant-exec-perms && yarn copy-files dist/ && cp -r src dist/",
|
||||
"generate:json-schema": "ts-node scripts/generate-json-schema.ts",
|
||||
"grant-exec-perms": "node dist/bin/tools/grant-exec-perms.js",
|
||||
"copy-files": "copyfiles -u 1 src/**/*.ftl",
|
||||
"copy-files": "copyfiles -u 1 src/**/*.ftl src/**/*.java",
|
||||
"test": "yarn test:types && vitest run",
|
||||
"test:keycloakify-starter": "ts-node scripts/test-keycloakify-starter",
|
||||
"test:types": "tsc -p test/tsconfig.json --noEmit",
|
||||
@ -24,6 +23,7 @@
|
||||
"generate-i18n-messages": "ts-node --skipProject scripts/generate-i18n-messages.ts",
|
||||
"link-in-app": "ts-node --skipProject scripts/link-in-app.ts",
|
||||
"link-in-starter": "yarn link-in-app keycloakify-starter",
|
||||
"watch-in-starter": "yarn build && yarn link-in-starter && (concurrently \"tsc -p src -w\" \"tsc-alias -p src/tsconfig.json\" \"tsc -p src/bin -w\")",
|
||||
"copy-keycloak-resources-to-storybook-static": "PUBLIC_DIR_PATH=.storybook/static node dist/bin/copy-keycloak-resources-to-public.js",
|
||||
"storybook": "yarn build && yarn copy-keycloak-resources-to-storybook-static && start-storybook -p 6006",
|
||||
"build-storybook": "yarn build && yarn copy-keycloak-resources-to-storybook-static && build-storybook"
|
||||
@ -79,12 +79,14 @@
|
||||
"@storybook/manager-webpack5": "^6.5.13",
|
||||
"@storybook/react": "^6.5.13",
|
||||
"@storybook/testing-library": "^0.0.13",
|
||||
"@types/babel__generator": "^7.6.4",
|
||||
"@types/make-fetch-happen": "^10.0.1",
|
||||
"@types/minimist": "^1.2.2",
|
||||
"@types/node": "^18.15.3",
|
||||
"@types/react": "^18.0.35",
|
||||
"@types/react-dom": "^18.0.11",
|
||||
"@types/yauzl": "^2.10.0",
|
||||
"@types/yazl": "^2.4.2",
|
||||
"concurrently": "^8.0.1",
|
||||
"copyfiles": "^2.4.1",
|
||||
"eslint-plugin-storybook": "^0.6.7",
|
||||
@ -106,8 +108,10 @@
|
||||
"zod-to-json-schema": "^3.20.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/generator": "^7.22.9",
|
||||
"@babel/parser": "^7.22.7",
|
||||
"@babel/types": "^7.22.5",
|
||||
"@octokit/rest": "^18.12.0",
|
||||
"@types/yazl": "^2.4.2",
|
||||
"cheerio": "^1.0.0-rc.5",
|
||||
"cli-select": "^1.1.2",
|
||||
"evt": "^2.4.18",
|
||||
@ -116,6 +120,7 @@
|
||||
"minimist": "^1.2.6",
|
||||
"path-browserify": "^1.0.1",
|
||||
"react-markdown": "^5.0.3",
|
||||
"recast": "^0.23.3",
|
||||
"rfc4648": "^1.5.2",
|
||||
"tsafe": "^1.6.0",
|
||||
"yauzl": "^2.10.0",
|
||||
|
@ -37,7 +37,10 @@ async function main() {
|
||||
const baseThemeDirPath = pathJoin(tmpDirPath, "base");
|
||||
const re = new RegExp(`^([^\\${pathSep}]+)\\${pathSep}messages\\${pathSep}messages_([^.]+).properties$`);
|
||||
|
||||
crawl(baseThemeDirPath).forEach(filePath => {
|
||||
crawl({
|
||||
"dirPath": baseThemeDirPath,
|
||||
"returnedPathsType": "relative to dirPath"
|
||||
}).forEach(filePath => {
|
||||
const match = filePath.match(re);
|
||||
|
||||
if (match === null) {
|
||||
|
@ -60,7 +60,7 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
|
||||
</div>
|
||||
</li>
|
||||
)}
|
||||
{referrer?.url !== undefined && (
|
||||
{referrer?.url && (
|
||||
<li>
|
||||
<a href={referrer.url} id="referrer">
|
||||
{msg("backTo", referrer.name)}
|
||||
|
@ -211,7 +211,9 @@ const keycloakifyExtraMessages = {
|
||||
"shouldBeDifferent": "{0} should be different to {1}",
|
||||
"shouldMatchPattern": "Pattern should match: `/{0}/`",
|
||||
"mustBeAnInteger": "Must be an integer",
|
||||
"notAValidOption": "Not a valid option"
|
||||
"notAValidOption": "Not a valid option",
|
||||
"newPasswordSameAsOld": "New password must be different from the old one",
|
||||
"passwordConfirmNotMatch": "Password confirmation does not match"
|
||||
},
|
||||
"fr": {
|
||||
/* spell-checker: disable */
|
||||
@ -223,7 +225,9 @@ const keycloakifyExtraMessages = {
|
||||
|
||||
"logoutConfirmTitle": "Déconnexion",
|
||||
"logoutConfirmHeader": "Êtes-vous sûr(e) de vouloir vous déconnecter ?",
|
||||
"doLogout": "Se déconnecter"
|
||||
"doLogout": "Se déconnecter",
|
||||
"newPasswordSameAsOld": "Le nouveau mot de passe doit être différent de l'ancien",
|
||||
"passwordConfirmNotMatch": "La confirmation du mot de passe ne correspond pas"
|
||||
/* spell-checker: enable */
|
||||
}
|
||||
};
|
||||
|
@ -4,6 +4,7 @@ export default Fallback;
|
||||
|
||||
export { getKcContext } from "keycloakify/account/kcContext/getKcContext";
|
||||
export { createGetKcContext } from "keycloakify/account/kcContext/createGetKcContext";
|
||||
export type { AccountThemePageId as PageId } from "keycloakify/bin/keycloakify/generateFtl";
|
||||
export { createUseI18n } from "keycloakify/account/i18n/i18n";
|
||||
|
||||
export type { PageProps } from "keycloakify/account/pages/PageProps";
|
||||
|
@ -1,4 +1,4 @@
|
||||
import type { AccountThemePageId } from "keycloakify/bin/keycloakify/generateFtl";
|
||||
import type { AccountThemePageId, ThemeType } from "keycloakify/bin/keycloakify/generateFtl";
|
||||
import { assert } from "tsafe/assert";
|
||||
import type { Equals } from "tsafe";
|
||||
|
||||
@ -7,6 +7,8 @@ export type KcContext = KcContext.Password | KcContext.Account;
|
||||
export declare namespace KcContext {
|
||||
export type Common = {
|
||||
keycloakifyVersion: string;
|
||||
themeType: "account";
|
||||
themeName: string;
|
||||
locale?: {
|
||||
supported: {
|
||||
url: string;
|
||||
@ -26,6 +28,7 @@ export declare namespace KcContext {
|
||||
resourceUrl: string;
|
||||
resourcesCommonPath: string;
|
||||
resourcesPath: string;
|
||||
/** @deprecated, not present in recent keycloak version apparently, use kcContext.referrer instead */
|
||||
referrerURI?: string;
|
||||
getLogoutUrl: () => string;
|
||||
};
|
||||
@ -39,18 +42,44 @@ export declare namespace KcContext {
|
||||
internationalizationEnabled: boolean;
|
||||
userManagedAccessAllowed: boolean;
|
||||
};
|
||||
// Present only if redirected to account page with ?referrer=xxx&referrer_uri=http...
|
||||
message?: {
|
||||
type: "success" | "warning" | "error" | "info";
|
||||
summary: string;
|
||||
};
|
||||
referrer?: {
|
||||
url?: string;
|
||||
name: string;
|
||||
url: string; // The url of the App
|
||||
name: string; // Client id
|
||||
};
|
||||
messagesPerField: {
|
||||
printIfExists: <T>(fieldName: string, x: T) => T | undefined;
|
||||
/**
|
||||
* Return text if message for given field exists. Useful eg. to add css styles for fields with message.
|
||||
*
|
||||
* @param fieldName to check for
|
||||
* @param text to return
|
||||
* @return text if message exists for given field, else undefined
|
||||
*/
|
||||
printIfExists: <T extends string>(fieldName: string, text: T) => T | undefined;
|
||||
/**
|
||||
* Check if exists error message for given fields
|
||||
*
|
||||
* @param fields
|
||||
* @return boolean
|
||||
*/
|
||||
existsError: (fieldName: string) => boolean;
|
||||
/**
|
||||
* Get message for given field.
|
||||
*
|
||||
* @param fieldName
|
||||
* @return message text or empty string
|
||||
*/
|
||||
get: (fieldName: string) => string;
|
||||
/**
|
||||
* Check if message for given field exists
|
||||
*
|
||||
* @param field
|
||||
* @return boolean
|
||||
*/
|
||||
exists: (fieldName: string) => boolean;
|
||||
};
|
||||
account: {
|
||||
@ -82,4 +111,15 @@ export declare namespace KcContext {
|
||||
};
|
||||
}
|
||||
|
||||
assert<Equals<KcContext["pageId"], AccountThemePageId>>();
|
||||
{
|
||||
type Got = KcContext["pageId"];
|
||||
type Expected = AccountThemePageId;
|
||||
|
||||
type OnlyInGot = Exclude<Got, Expected>;
|
||||
type OnlyInExpected = Exclude<Expected, Got>;
|
||||
|
||||
assert<Equals<OnlyInGot, never>>();
|
||||
assert<Equals<OnlyInExpected, never>>();
|
||||
}
|
||||
|
||||
assert<KcContext["themeType"] extends ThemeType ? true : false>();
|
||||
|
@ -7,8 +7,6 @@ import { pathBasename } from "keycloakify/tools/pathBasename";
|
||||
import { resourcesCommonDirPathRelativeToPublicDir } from "keycloakify/bin/mockTestingResourcesPath";
|
||||
import { symToStr } from "tsafe/symToStr";
|
||||
import { kcContextMocks, kcContextCommonMock } from "keycloakify/account/kcContext/kcContextMocks";
|
||||
import { id } from "tsafe/id";
|
||||
import { accountThemePageIds } from "keycloakify/bin/keycloakify/generateFtl/pageId";
|
||||
|
||||
export function createGetKcContext<KcContextExtension extends { pageId: string } = never>(params?: {
|
||||
mockData?: readonly DeepPartial<ExtendKcContext<KcContextExtension>>[];
|
||||
@ -87,7 +85,7 @@ export function createGetKcContext<KcContextExtension extends { pageId: string }
|
||||
return { "kcContext": undefined as any };
|
||||
}
|
||||
|
||||
if (id<readonly string[]>(accountThemePageIds).indexOf(realKcContext.pageId) < 0 && !("account" in realKcContext)) {
|
||||
if (realKcContext.themeType !== "account") {
|
||||
return { "kcContext": undefined as any };
|
||||
}
|
||||
|
||||
|
@ -4,10 +4,12 @@ import { pathJoin } from "keycloakify/bin/tools/pathJoin";
|
||||
import { id } from "tsafe/id";
|
||||
import type { KcContext } from "./KcContext";
|
||||
|
||||
const PUBLIC_URL = process.env["PUBLIC_URL"] ?? "/";
|
||||
const PUBLIC_URL = (typeof process !== "object" ? undefined : process.env?.["PUBLIC_URL"]) || "/";
|
||||
|
||||
export const kcContextCommonMock: KcContext.Common = {
|
||||
"keycloakifyVersion": "0.0.0",
|
||||
"themeType": "account",
|
||||
"themeName": "my-theme-name",
|
||||
"url": {
|
||||
"resourcesPath": pathJoin(PUBLIC_URL, resourcesDirPathRelativeToPublicDir),
|
||||
"resourcesCommonPath": pathJoin(PUBLIC_URL, resourcesCommonDirPathRelativeToPublicDir),
|
||||
|
@ -15,7 +15,7 @@ export default function Account(props: PageProps<Extract<KcContext, { pageId: "a
|
||||
}
|
||||
});
|
||||
|
||||
const { url, realm, messagesPerField, stateChecker, account } = kcContext;
|
||||
const { url, realm, messagesPerField, stateChecker, account, referrer } = kcContext;
|
||||
|
||||
const { msg } = i18n;
|
||||
|
||||
@ -99,7 +99,7 @@ export default function Account(props: PageProps<Extract<KcContext, { pageId: "a
|
||||
<div className="form-group">
|
||||
<div id="kc-form-buttons" className="col-md-offset-2 col-md-10 submit">
|
||||
<div>
|
||||
{url.referrerURI !== undefined && <a href={url.referrerURI}>{msg("backToApplication")}</a>}
|
||||
{referrer !== undefined && <a href={referrer?.url}>{msg("backToApplication")}</a>}
|
||||
<button
|
||||
type="submit"
|
||||
className={clsx(
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { clsx } from "keycloakify/tools/clsx";
|
||||
import type { PageProps } from "keycloakify/account/pages/PageProps";
|
||||
import { useGetClassName } from "keycloakify/account/lib/useGetClassName";
|
||||
@ -17,10 +18,69 @@ export default function Password(props: PageProps<Extract<KcContext, { pageId: "
|
||||
|
||||
const { url, password, account, stateChecker } = kcContext;
|
||||
|
||||
const { msg } = i18n;
|
||||
const { msgStr, msg } = i18n;
|
||||
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [newPasswordConfirm, setNewPasswordConfirm] = useState("");
|
||||
const [newPasswordError, setNewPasswordError] = useState("");
|
||||
const [newPasswordConfirmError, setNewPasswordConfirmError] = useState("");
|
||||
const [hasNewPasswordBlurred, setHasNewPasswordBlurred] = useState(false);
|
||||
const [hasNewPasswordConfirmBlurred, setHasNewPasswordConfirmBlurred] = useState(false);
|
||||
|
||||
const checkNewPassword = (newPassword: string) => {
|
||||
if (!password.passwordSet) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword === currentPassword) {
|
||||
setNewPasswordError(msgStr("newPasswordSameAsOld"));
|
||||
} else {
|
||||
setNewPasswordError("");
|
||||
}
|
||||
};
|
||||
|
||||
const checkNewPasswordConfirm = (newPasswordConfirm: string) => {
|
||||
if (newPasswordConfirm === "") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword !== newPasswordConfirm) {
|
||||
setNewPasswordConfirmError(msgStr("passwordConfirmNotMatch"));
|
||||
} else {
|
||||
setNewPasswordConfirmError("");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Template {...{ kcContext, i18n, doUseDefaultCss, classes }} active="password">
|
||||
<Template
|
||||
{...{
|
||||
kcContext: {
|
||||
...kcContext,
|
||||
"message": (() => {
|
||||
if (newPasswordError !== "") {
|
||||
return {
|
||||
"type": "error",
|
||||
"summary": newPasswordError
|
||||
};
|
||||
}
|
||||
|
||||
if (newPasswordConfirmError !== "") {
|
||||
return {
|
||||
"type": "error",
|
||||
"summary": newPasswordConfirmError
|
||||
};
|
||||
}
|
||||
|
||||
return kcContext.message;
|
||||
})()
|
||||
},
|
||||
i18n,
|
||||
doUseDefaultCss,
|
||||
classes
|
||||
}}
|
||||
active="password"
|
||||
>
|
||||
<div className="row">
|
||||
<div className="col-md-10">
|
||||
<h2>{msg("changePasswordHtmlTitle")}</h2>
|
||||
@ -48,9 +108,17 @@ export default function Password(props: PageProps<Extract<KcContext, { pageId: "
|
||||
{msg("password")}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="col-sm-10 col-md-10">
|
||||
<input type="password" className="form-control" id="password" name="password" autoFocus autoComplete="current-password" />
|
||||
<input
|
||||
type="password"
|
||||
className="form-control"
|
||||
id="password"
|
||||
name="password"
|
||||
autoFocus
|
||||
autoComplete="current-password"
|
||||
value={currentPassword}
|
||||
onChange={event => setCurrentPassword(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@ -63,9 +131,27 @@ export default function Password(props: PageProps<Extract<KcContext, { pageId: "
|
||||
{msg("passwordNew")}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="col-sm-10 col-md-10">
|
||||
<input type="password" className="form-control" id="password-new" name="password-new" autoComplete="new-password" />
|
||||
<input
|
||||
type="password"
|
||||
className="form-control"
|
||||
id="password-new"
|
||||
name="password-new"
|
||||
autoComplete="new-password"
|
||||
value={newPassword}
|
||||
onChange={event => {
|
||||
const newPassword = event.target.value;
|
||||
|
||||
setNewPassword(newPassword);
|
||||
if (hasNewPasswordBlurred) {
|
||||
checkNewPassword(newPassword);
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
setHasNewPasswordBlurred(true);
|
||||
checkNewPassword(newPassword);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -77,7 +163,26 @@ export default function Password(props: PageProps<Extract<KcContext, { pageId: "
|
||||
</div>
|
||||
|
||||
<div className="col-sm-10 col-md-10">
|
||||
<input type="password" className="form-control" id="password-confirm" name="password-confirm" autoComplete="new-password" />
|
||||
<input
|
||||
type="password"
|
||||
className="form-control"
|
||||
id="password-confirm"
|
||||
name="password-confirm"
|
||||
autoComplete="new-password"
|
||||
value={newPasswordConfirm}
|
||||
onChange={event => {
|
||||
const newPasswordConfirm = event.target.value;
|
||||
|
||||
setNewPasswordConfirm(newPasswordConfirm);
|
||||
if (hasNewPasswordConfirmBlurred) {
|
||||
checkNewPasswordConfirm(newPasswordConfirm);
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
setHasNewPasswordConfirmBlurred(true);
|
||||
checkNewPasswordConfirm(newPasswordConfirm);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -85,6 +190,7 @@ export default function Password(props: PageProps<Extract<KcContext, { pageId: "
|
||||
<div id="kc-form-buttons" className="col-md-offset-2 col-md-10 submit">
|
||||
<div>
|
||||
<button
|
||||
disabled={newPasswordError !== "" || newPasswordConfirmError !== ""}
|
||||
type="submit"
|
||||
className={clsx(
|
||||
getClassName("kcButtonClass"),
|
||||
|
@ -51,10 +51,6 @@ import { getThemeSrcDirPath } from "./getSrcDirPath";
|
||||
|
||||
const { themeSrcDirPath } = getThemeSrcDirPath({ "projectDirPath": process.cwd() });
|
||||
|
||||
if (themeSrcDirPath === undefined) {
|
||||
throw new Error("Couldn't locate your theme sources");
|
||||
}
|
||||
|
||||
const targetFilePath = pathJoin(themeSrcDirPath, themeType, "pages", pageBasename);
|
||||
|
||||
if (existsSync(targetFilePath)) {
|
||||
|
@ -2,15 +2,17 @@ import * as fs from "fs";
|
||||
import { exclude } from "tsafe";
|
||||
import { crawl } from "./tools/crawl";
|
||||
import { join as pathJoin } from "path";
|
||||
import { themeTypes } from "./keycloakify/generateFtl";
|
||||
|
||||
const themeSrcDirBasename = "keycloak-theme";
|
||||
|
||||
/** Can't catch error, if the directory isn't found, this function will just exit the process with an error message. */
|
||||
export function getThemeSrcDirPath(params: { projectDirPath: string }) {
|
||||
const { projectDirPath } = params;
|
||||
|
||||
const srcDirPath = pathJoin(projectDirPath, "src");
|
||||
|
||||
const themeSrcDirPath: string | undefined = crawl(srcDirPath)
|
||||
const themeSrcDirPath: string | undefined = crawl({ "dirPath": srcDirPath, "returnedPathsType": "relative to dirPath" })
|
||||
.map(fileRelativePath => {
|
||||
const split = fileRelativePath.split(themeSrcDirBasename);
|
||||
|
||||
@ -22,22 +24,24 @@ export function getThemeSrcDirPath(params: { projectDirPath: string }) {
|
||||
})
|
||||
.filter(exclude(undefined))[0];
|
||||
|
||||
if (themeSrcDirPath === undefined) {
|
||||
if (fs.existsSync(pathJoin(srcDirPath, "login")) || fs.existsSync(pathJoin(srcDirPath, "account"))) {
|
||||
return { "themeSrcDirPath": srcDirPath };
|
||||
}
|
||||
return { "themeSrcDirPath": undefined };
|
||||
if (themeSrcDirPath !== undefined) {
|
||||
return { themeSrcDirPath };
|
||||
}
|
||||
|
||||
return { themeSrcDirPath };
|
||||
}
|
||||
|
||||
export function getEmailThemeSrcDirPath(params: { projectDirPath: string }) {
|
||||
const { projectDirPath } = params;
|
||||
|
||||
const { themeSrcDirPath } = getThemeSrcDirPath({ projectDirPath });
|
||||
|
||||
const emailThemeSrcDirPath = themeSrcDirPath === undefined ? undefined : pathJoin(themeSrcDirPath, "email");
|
||||
|
||||
return { emailThemeSrcDirPath };
|
||||
for (const themeType of [...themeTypes, "email"]) {
|
||||
if (!fs.existsSync(pathJoin(srcDirPath, themeType))) {
|
||||
continue;
|
||||
}
|
||||
return { "themeSrcDirPath": srcDirPath };
|
||||
}
|
||||
|
||||
console.error(
|
||||
[
|
||||
"Can't locate your theme source directory. It should be either: ",
|
||||
"src/ or src/keycloak-theme.",
|
||||
"Example in the starter: https://github.com/keycloakify/keycloakify-starter/tree/main/src/keycloak-theme"
|
||||
].join("\n")
|
||||
);
|
||||
|
||||
process.exit(-1);
|
||||
}
|
||||
|
@ -7,7 +7,7 @@ import { promptKeycloakVersion } from "./promptKeycloakVersion";
|
||||
import { readBuildOptions } from "./keycloakify/BuildOptions";
|
||||
import * as fs from "fs";
|
||||
import { getLogger } from "./tools/logger";
|
||||
import { getEmailThemeSrcDirPath } from "./getSrcDirPath";
|
||||
import { getThemeSrcDirPath } from "./getSrcDirPath";
|
||||
|
||||
export async function main() {
|
||||
const { isSilent } = readBuildOptions({
|
||||
@ -17,15 +17,11 @@ export async function main() {
|
||||
|
||||
const logger = getLogger({ isSilent });
|
||||
|
||||
const { emailThemeSrcDirPath } = getEmailThemeSrcDirPath({
|
||||
const { themeSrcDirPath } = getThemeSrcDirPath({
|
||||
"projectDirPath": process.cwd()
|
||||
});
|
||||
|
||||
if (emailThemeSrcDirPath === undefined) {
|
||||
logger.warn("Couldn't locate your theme source directory");
|
||||
|
||||
process.exit(-1);
|
||||
}
|
||||
const emailThemeSrcDirPath = pathJoin(themeSrcDirPath, "email");
|
||||
|
||||
if (fs.existsSync(emailThemeSrcDirPath)) {
|
||||
logger.warn(`There is already a ${pathRelative(process.cwd(), emailThemeSrcDirPath)} directory in your project. Aborting.`);
|
||||
|
@ -16,9 +16,8 @@ export namespace BuildOptions {
|
||||
isSilent: boolean;
|
||||
themeVersion: string;
|
||||
themeName: string;
|
||||
extraLoginPages: string[] | undefined;
|
||||
extraAccountPages: string[] | undefined;
|
||||
extraThemeProperties?: string[];
|
||||
extraThemeNames: string[];
|
||||
extraThemeProperties: string[] | undefined;
|
||||
groupId: string;
|
||||
artifactId: string;
|
||||
bundler: Bundler;
|
||||
@ -27,7 +26,6 @@ export namespace BuildOptions {
|
||||
reactAppBuildDirPath: string;
|
||||
/** Directory that keycloakify outputs to. Defaults to {cwd}/build_keycloak */
|
||||
keycloakifyBuildDirPath: string;
|
||||
customUserAttributes: string[];
|
||||
};
|
||||
|
||||
export type Standalone = Common & {
|
||||
@ -108,8 +106,7 @@ export function readBuildOptions(params: { projectDirPath: string; processArgv:
|
||||
const common: BuildOptions.Common = (() => {
|
||||
const { name, keycloakify = {}, version, homepage } = parsedPackageJson;
|
||||
|
||||
const { extraPages, extraLoginPages, extraAccountPages, extraThemeProperties, groupId, artifactId, bundler, keycloakVersionDefaultAssets } =
|
||||
keycloakify ?? {};
|
||||
const { extraThemeProperties, groupId, artifactId, bundler, keycloakVersionDefaultAssets, extraThemeNames = [] } = keycloakify ?? {};
|
||||
|
||||
const themeName =
|
||||
keycloakify.themeName ??
|
||||
@ -120,6 +117,7 @@ export function readBuildOptions(params: { projectDirPath: string; processArgv:
|
||||
|
||||
return {
|
||||
themeName,
|
||||
extraThemeNames,
|
||||
"bundler": (() => {
|
||||
const { KEYCLOAKIFY_BUNDLER } = process.env;
|
||||
|
||||
@ -150,8 +148,6 @@ export function readBuildOptions(params: { projectDirPath: string; processArgv:
|
||||
);
|
||||
})(),
|
||||
"themeVersion": process.env.KEYCLOAKIFY_THEME_VERSION ?? process.env.KEYCLOAKIFY_VERSION ?? version ?? "0.0.0",
|
||||
"extraLoginPages": [...(extraPages ?? []), ...(extraLoginPages ?? [])],
|
||||
extraAccountPages,
|
||||
extraThemeProperties,
|
||||
"isSilent": isSilentCliParamProvided,
|
||||
"keycloakVersionDefaultAssets": keycloakVersionDefaultAssets ?? "11.0.3",
|
||||
@ -188,8 +184,7 @@ export function readBuildOptions(params: { projectDirPath: string; processArgv:
|
||||
}
|
||||
|
||||
return keycloakifyBuildDirPath;
|
||||
})(),
|
||||
"customUserAttributes": keycloakify.customUserAttributes ?? []
|
||||
})()
|
||||
};
|
||||
})();
|
||||
|
||||
|
@ -8,13 +8,7 @@
|
||||
out["advancedMsg"]= function(){ throw new Error("use import { useKcMessage } from 'keycloakify'"); };
|
||||
|
||||
out["messagesPerField"]= {
|
||||
<#assign fieldNames = [
|
||||
"global", "userLabel", "username", "email", "firstName", "lastName", "password", "password-confirm",
|
||||
"totp", "totpSecret", "SAMLRequest", "SAMLResponse", "relayState", "device_user_code", "code",
|
||||
"password-new", "rememberMe", "login", "authenticationExecution", "cancel-aia", "clientDataJSON",
|
||||
"authenticatorData", "signature", "credentialId", "userHandle", "error", "authn_use_chk", "authenticationExecution",
|
||||
"isSetRetry", "try-again", "attestationObject", "publicKeyCredentialId", "authenticatorLabel"CUSTOM_USER_ATTRIBUTES_eKsIY4ZsZ4xeM
|
||||
]>
|
||||
<#assign fieldNames = [ FIELD_NAMES_eKsIY4ZsZ4xeM ]>
|
||||
|
||||
<#attempt>
|
||||
<#if profile?? && profile.attributes?? && profile.attributes?is_enumerable>
|
||||
@ -28,85 +22,374 @@
|
||||
<#recover>
|
||||
</#attempt>
|
||||
|
||||
"printIfExists": function (fieldName, x) {
|
||||
<#if !messagesPerField?? >
|
||||
return undefined;
|
||||
"printIfExists": function (fieldName, text) {
|
||||
|
||||
<#if !messagesPerField?? || !(messagesPerField?is_hash)>
|
||||
throw new Error("You're not supposed to use messagesPerField.printIfExists in this page");
|
||||
<#else>
|
||||
<#list fieldNames as fieldName>
|
||||
if(fieldName === "${fieldName}" ){
|
||||
<#attempt>
|
||||
<#if '${fieldName}' == 'username' || '${fieldName}' == 'password'>
|
||||
return <#if messagesPerField.existsError('username', 'password')>x<#else>undefined</#if>;
|
||||
|
||||
<#-- https://github.com/keycloakify/keycloakify/pull/359 Compat with Keycloak prior v12 -->
|
||||
<#if !messagesPerField.existsError??>
|
||||
|
||||
<#-- https://github.com/keycloakify/keycloakify/pull/218 -->
|
||||
<#if ('${fieldName}' == 'username' || '${fieldName}' == 'password') && pageId != 'register.ftl' && pageId != 'register-user-profile.ftl'>
|
||||
|
||||
<#assign doExistMessageForUsernameOrPassword = "">
|
||||
|
||||
<#attempt>
|
||||
<#assign doExistMessageForUsernameOrPassword = messagesPerField.exists('username')>
|
||||
<#recover>
|
||||
<#assign doExistMessageForUsernameOrPassword = true>
|
||||
</#attempt>
|
||||
|
||||
<#if !doExistMessageForUsernameOrPassword>
|
||||
<#attempt>
|
||||
<#assign doExistMessageForUsernameOrPassword = messagesPerField.exists('password')>
|
||||
<#recover>
|
||||
<#assign doExistMessageForUsernameOrPassword = true>
|
||||
</#attempt>
|
||||
</#if>
|
||||
|
||||
return <#if doExistMessageForUsernameOrPassword>text<#else>undefined</#if>;
|
||||
|
||||
<#else>
|
||||
return <#if messagesPerField.existsError('${fieldName}')>x<#else>undefined</#if>;
|
||||
|
||||
<#assign doExistMessageForField = "">
|
||||
|
||||
<#attempt>
|
||||
<#assign doExistMessageForField = messagesPerField.exists('${fieldName}')>
|
||||
<#recover>
|
||||
<#assign doExistMessageForField = true>
|
||||
</#attempt>
|
||||
|
||||
return <#if doExistMessageForField>text<#else>undefined</#if>;
|
||||
|
||||
</#if>
|
||||
<#recover>
|
||||
</#attempt>
|
||||
|
||||
<#else>
|
||||
|
||||
<#-- https://github.com/keycloakify/keycloakify/pull/218 -->
|
||||
<#if ('${fieldName}' == 'username' || '${fieldName}' == 'password') && pageId != 'register.ftl' && pageId != 'register-user-profile.ftl'>
|
||||
|
||||
<#assign doExistErrorOnUsernameOrPassword = "">
|
||||
|
||||
<#attempt>
|
||||
<#assign doExistErrorOnUsernameOrPassword = messagesPerField.existsError('username', 'password')>
|
||||
<#recover>
|
||||
<#assign doExistErrorOnUsernameOrPassword = true>
|
||||
</#attempt>
|
||||
|
||||
<#if doExistErrorOnUsernameOrPassword>
|
||||
return text;
|
||||
<#else>
|
||||
|
||||
<#assign doExistMessageForField = "">
|
||||
|
||||
<#attempt>
|
||||
<#assign doExistMessageForField = messagesPerField.exists('${fieldName}')>
|
||||
<#recover>
|
||||
<#assign doExistMessageForField = true>
|
||||
</#attempt>
|
||||
|
||||
return <#if doExistMessageForField>text<#else>undefined</#if>;
|
||||
|
||||
</#if>
|
||||
|
||||
<#else>
|
||||
|
||||
<#assign doExistMessageForField = "">
|
||||
|
||||
<#attempt>
|
||||
<#assign doExistMessageForField = messagesPerField.exists('${fieldName}')>
|
||||
<#recover>
|
||||
<#assign doExistMessageForField = true>
|
||||
</#attempt>
|
||||
|
||||
return <#if doExistMessageForField>text<#else>undefined</#if>;
|
||||
|
||||
</#if>
|
||||
|
||||
</#if>
|
||||
|
||||
}
|
||||
</#list>
|
||||
throw new Error("There is no " + fieldName + " field");
|
||||
|
||||
throw new Error(fieldName + "is probably runtime generated, see: https://docs.keycloakify.dev/limitations#field-names-cant-be-runtime-generated");
|
||||
</#if>
|
||||
|
||||
},
|
||||
"existsError": function (fieldName) {
|
||||
<#if !messagesPerField?? >
|
||||
return false;
|
||||
|
||||
<#if !messagesPerField?? || !(messagesPerField?is_hash)>
|
||||
throw new Error("You're not supposed to use messagesPerField.printIfExists in this page");
|
||||
<#else>
|
||||
<#list fieldNames as fieldName>
|
||||
if(fieldName === "${fieldName}" ){
|
||||
<#attempt>
|
||||
<#if '${fieldName}' == 'username' || '${fieldName}' == 'password'>
|
||||
return <#if messagesPerField.existsError('username', 'password')>true<#else>false</#if>;
|
||||
|
||||
<#-- https://github.com/keycloakify/keycloakify/pull/359 Compat with Keycloak prior v12 -->
|
||||
<#if !messagesPerField.existsError??>
|
||||
|
||||
<#-- https://github.com/keycloakify/keycloakify/pull/218 -->
|
||||
<#if ('${fieldName}' == 'username' || '${fieldName}' == 'password') && pageId != 'register.ftl' && pageId != 'register-user-profile.ftl'>
|
||||
|
||||
<#assign doExistMessageForUsernameOrPassword = "">
|
||||
|
||||
<#attempt>
|
||||
<#assign doExistMessageForUsernameOrPassword = messagesPerField.exists('username')>
|
||||
<#recover>
|
||||
<#assign doExistMessageForUsernameOrPassword = true>
|
||||
</#attempt>
|
||||
|
||||
<#if !doExistMessageForUsernameOrPassword>
|
||||
<#attempt>
|
||||
<#assign doExistMessageForUsernameOrPassword = messagesPerField.exists('password')>
|
||||
<#recover>
|
||||
<#assign doExistMessageForUsernameOrPassword = true>
|
||||
</#attempt>
|
||||
</#if>
|
||||
|
||||
return <#if doExistMessageForUsernameOrPassword>true<#else>false</#if>;
|
||||
|
||||
<#else>
|
||||
return <#if messagesPerField.existsError('${fieldName}')>true<#else>false</#if>;
|
||||
|
||||
<#assign doExistMessageForField = "">
|
||||
|
||||
<#attempt>
|
||||
<#assign doExistMessageForField = messagesPerField.exists('${fieldName}')>
|
||||
<#recover>
|
||||
<#assign doExistMessageForField = true>
|
||||
</#attempt>
|
||||
|
||||
return <#if doExistMessageForField>true<#else>false</#if>;
|
||||
|
||||
</#if>
|
||||
<#recover>
|
||||
</#attempt>
|
||||
|
||||
<#else>
|
||||
|
||||
<#-- https://github.com/keycloakify/keycloakify/pull/218 -->
|
||||
<#if ('${fieldName}' == 'username' || '${fieldName}' == 'password') && pageId != 'register.ftl' && pageId != 'register-user-profile.ftl'>
|
||||
|
||||
<#assign doExistErrorOnUsernameOrPassword = "">
|
||||
|
||||
<#attempt>
|
||||
<#assign doExistErrorOnUsernameOrPassword = messagesPerField.existsError('username', 'password')>
|
||||
<#recover>
|
||||
<#assign doExistErrorOnUsernameOrPassword = true>
|
||||
</#attempt>
|
||||
|
||||
return <#if doExistErrorOnUsernameOrPassword>true<#else>false</#if>;
|
||||
|
||||
<#else>
|
||||
|
||||
<#assign doExistErrorMessageForField = "">
|
||||
|
||||
<#attempt>
|
||||
<#assign doExistErrorMessageForField = messagesPerField.existsError('${fieldName}')>
|
||||
<#recover>
|
||||
<#assign doExistErrorMessageForField = true>
|
||||
</#attempt>
|
||||
|
||||
return <#if doExistErrorMessageForField>true<#else>false</#if>;
|
||||
|
||||
</#if>
|
||||
|
||||
</#if>
|
||||
|
||||
}
|
||||
</#list>
|
||||
throw new Error("There is no " + fieldName + " field");
|
||||
|
||||
throw new Error(fieldName + "is probably runtime generated, see: https://docs.keycloakify.dev/limitations#field-names-cant-be-runtime-generated");
|
||||
|
||||
</#if>
|
||||
|
||||
},
|
||||
"get": function (fieldName) {
|
||||
<#if !messagesPerField?? >
|
||||
return '';
|
||||
|
||||
|
||||
<#if !messagesPerField?? || !(messagesPerField?is_hash)>
|
||||
throw new Error("You're not supposed to use messagesPerField.get in this page");
|
||||
<#else>
|
||||
<#list fieldNames as fieldName>
|
||||
if(fieldName === "${fieldName}" ){
|
||||
<#attempt>
|
||||
<#if '${fieldName}' == 'username' || '${fieldName}' == 'password'>
|
||||
<#if messagesPerField.existsError('username', 'password')>
|
||||
return 'Invalid username or password.';
|
||||
|
||||
<#-- https://github.com/keycloakify/keycloakify/pull/359 Compat with Keycloak prior v12 -->
|
||||
<#if !messagesPerField.existsError??>
|
||||
|
||||
<#-- https://github.com/keycloakify/keycloakify/pull/218 -->
|
||||
<#if ('${fieldName}' == 'username' || '${fieldName}' == 'password') && pageId != 'register.ftl' && pageId != 'register-user-profile.ftl'>
|
||||
|
||||
<#assign doExistMessageForUsernameOrPassword = "">
|
||||
|
||||
<#attempt>
|
||||
<#assign doExistMessageForUsernameOrPassword = messagesPerField.exists('username')>
|
||||
<#recover>
|
||||
<#assign doExistMessageForUsernameOrPassword = true>
|
||||
</#attempt>
|
||||
|
||||
<#if !doExistMessageForUsernameOrPassword>
|
||||
<#attempt>
|
||||
<#assign doExistMessageForUsernameOrPassword = messagesPerField.exists('password')>
|
||||
<#recover>
|
||||
<#assign doExistMessageForUsernameOrPassword = true>
|
||||
</#attempt>
|
||||
</#if>
|
||||
|
||||
<#if !doExistMessageForUsernameOrPassword>
|
||||
return "";
|
||||
<#else>
|
||||
<#attempt>
|
||||
return "${kcSanitize(msg('invalidUserMessage'))?no_esc}";
|
||||
<#recover>
|
||||
return "Invalid username or password.";
|
||||
</#attempt>
|
||||
</#if>
|
||||
|
||||
<#else>
|
||||
<#if messagesPerField.existsError('${fieldName}')>
|
||||
|
||||
<#attempt>
|
||||
return "${messagesPerField.get('${fieldName}')?no_esc}";
|
||||
</#if>
|
||||
<#recover>
|
||||
return "invalid field";
|
||||
</#attempt>
|
||||
|
||||
</#if>
|
||||
<#recover>
|
||||
</#attempt>
|
||||
|
||||
<#else>
|
||||
|
||||
<#-- https://github.com/keycloakify/keycloakify/pull/218 -->
|
||||
<#if ('${fieldName}' == 'username' || '${fieldName}' == 'password') && pageId != 'register.ftl' && pageId != 'register-user-profile.ftl'>
|
||||
|
||||
<#assign doExistErrorOnUsernameOrPassword = "">
|
||||
|
||||
<#attempt>
|
||||
<#assign doExistErrorOnUsernameOrPassword = messagesPerField.existsError('username', 'password')>
|
||||
<#recover>
|
||||
<#assign doExistErrorOnUsernameOrPassword = true>
|
||||
</#attempt>
|
||||
|
||||
<#if doExistErrorOnUsernameOrPassword>
|
||||
|
||||
<#attempt>
|
||||
return "${kcSanitize(msg('invalidUserMessage'))?no_esc}";
|
||||
<#recover>
|
||||
return "Invalid username or password.";
|
||||
</#attempt>
|
||||
|
||||
<#else>
|
||||
|
||||
<#attempt>
|
||||
return "${messagesPerField.get('${fieldName}')?no_esc}";
|
||||
<#recover>
|
||||
return "";
|
||||
</#attempt>
|
||||
|
||||
</#if>
|
||||
|
||||
<#else>
|
||||
|
||||
<#attempt>
|
||||
return "${messagesPerField.get('${fieldName}')?no_esc}";
|
||||
<#recover>
|
||||
return "invalid field";
|
||||
</#attempt>
|
||||
|
||||
</#if>
|
||||
|
||||
</#if>
|
||||
|
||||
}
|
||||
</#list>
|
||||
throw new Error("There is no " + fieldName + " field");
|
||||
|
||||
throw new Error(fieldName + "is probably runtime generated, see: https://docs.keycloakify.dev/limitations#field-names-cant-be-runtime-generated");
|
||||
|
||||
</#if>
|
||||
|
||||
},
|
||||
"exists": function (fieldName) {
|
||||
<#if !messagesPerField?? >
|
||||
return false;
|
||||
|
||||
<#if !messagesPerField?? || !(messagesPerField?is_hash)>
|
||||
throw new Error("You're not supposed to use messagesPerField.exists in this page");
|
||||
<#else>
|
||||
<#list fieldNames as fieldName>
|
||||
if(fieldName === "${fieldName}" ){
|
||||
<#attempt>
|
||||
<#if '${fieldName}' == 'username' || '${fieldName}' == 'password'>
|
||||
return <#if messagesPerField.exists('username') || messagesPerField.exists('password')>true<#else>false</#if>;
|
||||
|
||||
<#-- https://github.com/keycloakify/keycloakify/pull/359 Compat with Keycloak prior v12 -->
|
||||
<#if !messagesPerField.existsError??>
|
||||
|
||||
<#-- https://github.com/keycloakify/keycloakify/pull/218 -->
|
||||
<#if ('${fieldName}' == 'username' || '${fieldName}' == 'password') && pageId != 'register.ftl' && pageId != 'register-user-profile.ftl'>
|
||||
|
||||
<#assign doExistMessageForUsernameOrPassword = "">
|
||||
|
||||
<#attempt>
|
||||
<#assign doExistMessageForUsernameOrPassword = messagesPerField.exists('username')>
|
||||
<#recover>
|
||||
<#assign doExistMessageForUsernameOrPassword = true>
|
||||
</#attempt>
|
||||
|
||||
<#if !doExistMessageForUsernameOrPassword>
|
||||
<#attempt>
|
||||
<#assign doExistMessageForUsernameOrPassword = messagesPerField.exists('password')>
|
||||
<#recover>
|
||||
<#assign doExistMessageForUsernameOrPassword = true>
|
||||
</#attempt>
|
||||
</#if>
|
||||
|
||||
return <#if doExistMessageForUsernameOrPassword>true<#else>false</#if>;
|
||||
|
||||
<#else>
|
||||
return <#if messagesPerField.exists('${fieldName}')>true<#else>false</#if>;
|
||||
|
||||
<#assign doExistMessageForField = "">
|
||||
|
||||
<#attempt>
|
||||
<#assign doExistMessageForField = messagesPerField.exists('${fieldName}')>
|
||||
<#recover>
|
||||
<#assign doExistMessageForField = true>
|
||||
</#attempt>
|
||||
|
||||
return <#if doExistMessageForField>true<#else>false</#if>;
|
||||
|
||||
</#if>
|
||||
<#recover>
|
||||
</#attempt>
|
||||
|
||||
<#else>
|
||||
|
||||
<#-- https://github.com/keycloakify/keycloakify/pull/218 -->
|
||||
<#if ('${fieldName}' == 'username' || '${fieldName}' == 'password') && pageId != 'register.ftl' && pageId != 'register-user-profile.ftl'>
|
||||
|
||||
<#assign doExistErrorOnUsernameOrPassword = "">
|
||||
|
||||
<#attempt>
|
||||
<#assign doExistErrorOnUsernameOrPassword = messagesPerField.existsError('username', 'password')>
|
||||
<#recover>
|
||||
<#assign doExistErrorOnUsernameOrPassword = true>
|
||||
</#attempt>
|
||||
|
||||
return <#if doExistErrorOnUsernameOrPassword>true<#else>false</#if>;
|
||||
|
||||
<#else>
|
||||
|
||||
<#assign doExistErrorMessageForField = "">
|
||||
|
||||
<#attempt>
|
||||
<#assign doExistErrorMessageForField = messagesPerField.exists('${fieldName}')>
|
||||
<#recover>
|
||||
<#assign doExistErrorMessageForField = true>
|
||||
</#attempt>
|
||||
|
||||
return <#if doExistErrorMessageForField>true<#else>false</#if>;
|
||||
|
||||
</#if>
|
||||
|
||||
</#if>
|
||||
|
||||
}
|
||||
</#list>
|
||||
throw new Error("There is no " + fieldName + " field");
|
||||
|
||||
throw new Error(fieldName + "is probably runtime generated, see: https://docs.keycloakify.dev/limitations#field-names-cant-be-runtime-generated");
|
||||
</#if>
|
||||
|
||||
}
|
||||
};
|
||||
|
||||
@ -121,6 +404,8 @@
|
||||
|
||||
out["keycloakifyVersion"] = "KEYCLOAKIFY_VERSION_xEdKd3xEdr";
|
||||
out["themeVersion"] = "KEYCLOAKIFY_THEME_VERSION_sIgKd3xEdr3dx";
|
||||
out["themeType"] = "KEYCLOAKIFY_THEME_TYPE_dExKd3xEdr";
|
||||
out["themeName"] = "KEYCLOAKIFY_THEME_NAME_cXxKd3xEer";
|
||||
out["pageId"] = "${pageId}";
|
||||
|
||||
return out;
|
||||
@ -169,10 +454,15 @@
|
||||
<#-- https://github.com/keycloakify/keycloakify/pull/65#issuecomment-991896344 (reports with saml-post-form.ftl) -->
|
||||
<#-- https://github.com/keycloakify/keycloakify/issues/91#issue-1212319466 (reports with error.ftl and Kc18) -->
|
||||
<#-- https://github.com/keycloakify/keycloakify/issues/109#issuecomment-1134610163 -->
|
||||
<#-- https://github.com/keycloakify/keycloakify/issues/357 -->
|
||||
key == "loginAction" &&
|
||||
are_same_path(path, ["url"]) &&
|
||||
["saml-post-form.ftl", "error.ftl", "info.ftl"]?seq_contains(pageId) &&
|
||||
["saml-post-form.ftl", "error.ftl", "info.ftl", "login-oauth-grant.ftl", "logout-confirm.ftl"]?seq_contains(pageId) &&
|
||||
!(auth?has_content && auth.showTryAnotherWayLink())
|
||||
) || (
|
||||
<#-- https://github.com/keycloakify/keycloakify/issues/362 -->
|
||||
["secretData", "value"]?seq_contains(key) &&
|
||||
are_same_path(path, [ "totp", "otpCredentials", "*" ])
|
||||
) || (
|
||||
["contextData", "idpConfig", "idp", "authenticationSession"]?seq_contains(key) &&
|
||||
are_same_path(path, ["brokerContext"]) &&
|
||||
@ -336,6 +626,17 @@
|
||||
|
||||
</#if>
|
||||
|
||||
<#local isDate = "">
|
||||
<#attempt>
|
||||
<#local isDate = object?is_date_like>
|
||||
<#recover>
|
||||
<#return "ABORT: Can't test if it's a date">
|
||||
</#attempt>
|
||||
|
||||
<#if isDate>
|
||||
<#return '"' + object?datetime?iso_utc + '"'>
|
||||
</#if>
|
||||
|
||||
<#attempt>
|
||||
<#return '"' + object?js_string + '"'>;
|
||||
<#recover>
|
||||
|
@ -17,7 +17,7 @@ export type BuildOptionsLike = BuildOptionsLike.Standalone | BuildOptionsLike.Ex
|
||||
|
||||
export namespace BuildOptionsLike {
|
||||
export type Common = {
|
||||
customUserAttributes: string[];
|
||||
themeName: string;
|
||||
themeVersion: string;
|
||||
};
|
||||
|
||||
@ -55,8 +55,10 @@ export function generateFtlFilesCodeFactory(params: {
|
||||
cssGlobalsToDefine: Record<string, string>;
|
||||
buildOptions: BuildOptionsLike;
|
||||
keycloakifyVersion: string;
|
||||
themeType: ThemeType;
|
||||
fieldNames: string[];
|
||||
}) {
|
||||
const { cssGlobalsToDefine, indexHtmlCode, buildOptions, keycloakifyVersion } = params;
|
||||
const { cssGlobalsToDefine, indexHtmlCode, buildOptions, keycloakifyVersion, themeType, fieldNames } = params;
|
||||
|
||||
const $ = cheerio.load(indexHtmlCode);
|
||||
|
||||
@ -127,12 +129,11 @@ export function generateFtlFilesCodeFactory(params: {
|
||||
.readFileSync(pathJoin(__dirname, "ftl_object_to_js_code_declaring_an_object.ftl"))
|
||||
.toString("utf8")
|
||||
.match(/^<script>const _=((?:.|\n)+)<\/script>[\n]?$/)![1]
|
||||
.replace(
|
||||
"CUSTOM_USER_ATTRIBUTES_eKsIY4ZsZ4xeM",
|
||||
buildOptions.customUserAttributes.length === 0 ? "" : ", " + buildOptions.customUserAttributes.map(name => `"${name}"`).join(", ")
|
||||
)
|
||||
.replace("FIELD_NAMES_eKsIY4ZsZ4xeM", fieldNames.map(name => `"${name}"`).join(", "))
|
||||
.replace("KEYCLOAKIFY_VERSION_xEdKd3xEdr", keycloakifyVersion)
|
||||
.replace("KEYCLOAKIFY_THEME_VERSION_sIgKd3xEdr3dx", buildOptions.themeVersion),
|
||||
.replace("KEYCLOAKIFY_THEME_VERSION_sIgKd3xEdr3dx", buildOptions.themeVersion)
|
||||
.replace("KEYCLOAKIFY_THEME_TYPE_dExKd3xEdr", themeType)
|
||||
.replace("KEYCLOAKIFY_THEME_NAME_cXxKd3xEer", buildOptions.themeName),
|
||||
"<!-- xIdLqMeOedErIdLsPdNdI9dSlxI -->": [
|
||||
"<#if scripts??>",
|
||||
" <#list scripts as script>",
|
||||
|
@ -21,7 +21,8 @@ export const loginThemePageIds = [
|
||||
"update-user-profile.ftl",
|
||||
"idp-review-user-profile.ftl",
|
||||
"update-email.ftl",
|
||||
"select-authenticator.ftl"
|
||||
"select-authenticator.ftl",
|
||||
"saml-post-form.ftl"
|
||||
] as const;
|
||||
|
||||
export const accountThemePageIds = ["password.ftl", "account.ftl"] as const;
|
||||
|
@ -1,88 +0,0 @@
|
||||
import * as fs from "fs";
|
||||
import { join as pathJoin, dirname as pathDirname } from "path";
|
||||
import { themeTypes } from "./generateFtl/generateFtl";
|
||||
import { assert } from "tsafe/assert";
|
||||
import { Reflect } from "tsafe/Reflect";
|
||||
import type { BuildOptions } from "./BuildOptions";
|
||||
|
||||
export type BuildOptionsLike = {
|
||||
themeName: string;
|
||||
groupId: string;
|
||||
artifactId?: string;
|
||||
themeVersion: string;
|
||||
};
|
||||
|
||||
{
|
||||
const buildOptions = Reflect<BuildOptions>();
|
||||
|
||||
assert<typeof buildOptions extends BuildOptionsLike ? true : false>();
|
||||
}
|
||||
|
||||
export function generateJavaStackFiles(params: {
|
||||
keycloakThemeBuildingDirPath: string;
|
||||
doBundlesEmailTemplate: boolean;
|
||||
buildOptions: BuildOptionsLike;
|
||||
}): {
|
||||
jarFilePath: string;
|
||||
} {
|
||||
const {
|
||||
buildOptions: { groupId, themeName, themeVersion, artifactId },
|
||||
keycloakThemeBuildingDirPath,
|
||||
doBundlesEmailTemplate
|
||||
} = params;
|
||||
|
||||
{
|
||||
const { pomFileCode } = (function generatePomFileCode(): {
|
||||
pomFileCode: string;
|
||||
} {
|
||||
const pomFileCode = [
|
||||
`<?xml version="1.0"?>`,
|
||||
`<project xmlns="http://maven.apache.org/POM/4.0.0"`,
|
||||
` xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"`,
|
||||
` xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">`,
|
||||
` <modelVersion>4.0.0</modelVersion>`,
|
||||
` <groupId>${groupId}</groupId>`,
|
||||
` <artifactId>${artifactId}</artifactId>`,
|
||||
` <version>${themeVersion}</version>`,
|
||||
` <name>${artifactId}</name>`,
|
||||
` <description />`,
|
||||
`</project>`
|
||||
].join("\n");
|
||||
|
||||
return { pomFileCode };
|
||||
})();
|
||||
|
||||
fs.writeFileSync(pathJoin(keycloakThemeBuildingDirPath, "pom.xml"), Buffer.from(pomFileCode, "utf8"));
|
||||
}
|
||||
|
||||
{
|
||||
const themeManifestFilePath = pathJoin(keycloakThemeBuildingDirPath, "src", "main", "resources", "META-INF", "keycloak-themes.json");
|
||||
|
||||
try {
|
||||
fs.mkdirSync(pathDirname(themeManifestFilePath));
|
||||
} catch {}
|
||||
|
||||
fs.writeFileSync(
|
||||
themeManifestFilePath,
|
||||
Buffer.from(
|
||||
JSON.stringify(
|
||||
{
|
||||
"themes": [
|
||||
{
|
||||
"name": themeName,
|
||||
"types": [...themeTypes, ...(doBundlesEmailTemplate ? ["email"] : [])]
|
||||
}
|
||||
]
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
"utf8"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
"jarFilePath": pathJoin(keycloakThemeBuildingDirPath, "target", `${artifactId}-${themeVersion}.jar`)
|
||||
};
|
||||
}
|
@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright 2016 Red Hat, Inc. and/or its affiliates
|
||||
* and other contributors as indicated by the @author tags.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.keycloak.forms.account;
|
||||
|
||||
/**
|
||||
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
|
||||
*/
|
||||
public enum AccountPages {
|
||||
ACCOUNT,
|
||||
PASSWORD,
|
||||
TOTP,
|
||||
FEDERATED_IDENTITY,
|
||||
LOG,
|
||||
SESSIONS,
|
||||
APPLICATIONS,
|
||||
RESOURCES,
|
||||
RESOURCE_DETAIL;
|
||||
}
|
@ -0,0 +1,76 @@
|
||||
/*
|
||||
* Copyright 2016 Red Hat, Inc. and/or its affiliates
|
||||
* and other contributors as indicated by the @author tags.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.keycloak.forms.account;
|
||||
|
||||
import jakarta.ws.rs.core.HttpHeaders;
|
||||
import jakarta.ws.rs.core.MultivaluedMap;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import jakarta.ws.rs.core.UriInfo;
|
||||
import java.util.List;
|
||||
import org.keycloak.events.Event;
|
||||
import org.keycloak.models.RealmModel;
|
||||
import org.keycloak.models.UserModel;
|
||||
import org.keycloak.models.UserSessionModel;
|
||||
import org.keycloak.models.utils.FormMessage;
|
||||
import org.keycloak.provider.Provider;
|
||||
|
||||
/**
|
||||
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
|
||||
*/
|
||||
public interface AccountProvider extends Provider {
|
||||
|
||||
AccountProvider setUriInfo(UriInfo uriInfo);
|
||||
|
||||
AccountProvider setHttpHeaders(HttpHeaders httpHeaders);
|
||||
|
||||
Response createResponse(AccountPages page);
|
||||
|
||||
AccountProvider setError(Response.Status status, String message, Object... parameters);
|
||||
|
||||
AccountProvider setErrors(Response.Status status, List<FormMessage> messages);
|
||||
|
||||
AccountProvider setSuccess(String message, Object... parameters);
|
||||
|
||||
AccountProvider setWarning(String message, Object... parameters);
|
||||
|
||||
AccountProvider setUser(UserModel user);
|
||||
|
||||
AccountProvider setProfileFormData(MultivaluedMap<String, String> formData);
|
||||
|
||||
AccountProvider setRealm(RealmModel realm);
|
||||
|
||||
AccountProvider setReferrer(String[] referrer);
|
||||
|
||||
AccountProvider setEvents(List<Event> events);
|
||||
|
||||
AccountProvider setSessions(List<UserSessionModel> sessions);
|
||||
|
||||
AccountProvider setPasswordSet(boolean passwordSet);
|
||||
|
||||
AccountProvider setStateChecker(String stateChecker);
|
||||
|
||||
AccountProvider setIdTokenHint(String idTokenHint);
|
||||
|
||||
AccountProvider setFeatures(
|
||||
boolean social,
|
||||
boolean events,
|
||||
boolean passwordUpdateSupported,
|
||||
boolean authorizationSupported);
|
||||
|
||||
AccountProvider setAttribute(String key, String value);
|
||||
}
|
@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Copyright 2016 Red Hat, Inc. and/or its affiliates
|
||||
* and other contributors as indicated by the @author tags.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.keycloak.forms.account;
|
||||
|
||||
import org.keycloak.provider.ProviderFactory;
|
||||
|
||||
/**
|
||||
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
|
||||
*/
|
||||
public interface AccountProviderFactory extends ProviderFactory<AccountProvider> {}
|
@ -0,0 +1,50 @@
|
||||
/*
|
||||
* Copyright 2016 Red Hat, Inc. and/or its affiliates
|
||||
* and other contributors as indicated by the @author tags.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.keycloak.forms.account;
|
||||
|
||||
import com.google.auto.service.AutoService;
|
||||
import org.keycloak.provider.Provider;
|
||||
import org.keycloak.provider.ProviderFactory;
|
||||
import org.keycloak.provider.Spi;
|
||||
|
||||
/**
|
||||
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
|
||||
*/
|
||||
@AutoService(Spi.class)
|
||||
public class AccountSpi implements Spi {
|
||||
|
||||
@Override
|
||||
public boolean isInternal() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return "account";
|
||||
}
|
||||
|
||||
@Override
|
||||
public Class<? extends Provider> getProviderClass() {
|
||||
return AccountProvider.class;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Class<? extends ProviderFactory> getProviderFactoryClass() {
|
||||
return AccountProviderFactory.class;
|
||||
}
|
||||
}
|
@ -0,0 +1,424 @@
|
||||
/*
|
||||
* Copyright 2022 Red Hat, Inc. and/or its affiliates
|
||||
* and other contributors as indicated by the @author tags.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package org.keycloak.forms.account.freemarker;
|
||||
|
||||
import jakarta.ws.rs.core.HttpHeaders;
|
||||
import jakarta.ws.rs.core.MultivaluedMap;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import jakarta.ws.rs.core.Response.Status;
|
||||
import jakarta.ws.rs.core.UriBuilder;
|
||||
import jakarta.ws.rs.core.UriInfo;
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.text.MessageFormat;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Properties;
|
||||
import org.jboss.logging.Logger;
|
||||
import org.keycloak.events.Event;
|
||||
import org.keycloak.forms.account.AccountPages;
|
||||
import org.keycloak.forms.account.AccountProvider;
|
||||
import org.keycloak.forms.account.freemarker.model.AccountBean;
|
||||
import org.keycloak.forms.account.freemarker.model.AccountFederatedIdentityBean;
|
||||
import org.keycloak.forms.account.freemarker.model.ApplicationsBean;
|
||||
import org.keycloak.forms.account.freemarker.model.AuthorizationBean;
|
||||
import org.keycloak.forms.account.freemarker.model.FeaturesBean;
|
||||
import org.keycloak.forms.account.freemarker.model.LogBean;
|
||||
import org.keycloak.forms.account.freemarker.model.PasswordBean;
|
||||
import org.keycloak.forms.account.freemarker.model.RealmBean;
|
||||
import org.keycloak.forms.account.freemarker.model.ReferrerBean;
|
||||
import org.keycloak.forms.account.freemarker.model.SessionsBean;
|
||||
import org.keycloak.forms.account.freemarker.model.TotpBean;
|
||||
import org.keycloak.forms.account.freemarker.model.UrlBean;
|
||||
import org.keycloak.forms.login.MessageType;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.RealmModel;
|
||||
import org.keycloak.models.UserModel;
|
||||
import org.keycloak.models.UserSessionModel;
|
||||
import org.keycloak.models.utils.FormMessage;
|
||||
import org.keycloak.services.util.CacheControlUtil;
|
||||
import org.keycloak.theme.FreeMarkerException;
|
||||
import org.keycloak.theme.Theme;
|
||||
import org.keycloak.theme.beans.AdvancedMessageFormatterMethod;
|
||||
import org.keycloak.theme.beans.LocaleBean;
|
||||
import org.keycloak.theme.beans.MessageBean;
|
||||
import org.keycloak.theme.beans.MessageFormatterMethod;
|
||||
import org.keycloak.theme.beans.MessagesPerFieldBean;
|
||||
import org.keycloak.theme.freemarker.FreeMarkerProvider;
|
||||
import org.keycloak.utils.MediaType;
|
||||
import org.keycloak.utils.StringUtil;
|
||||
|
||||
/**
|
||||
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
|
||||
*/
|
||||
public class FreeMarkerAccountProvider implements AccountProvider {
|
||||
|
||||
private static final Logger logger = Logger.getLogger(FreeMarkerAccountProvider.class);
|
||||
|
||||
protected UserModel user;
|
||||
protected MultivaluedMap<String, String> profileFormData;
|
||||
protected Response.Status status = Response.Status.OK;
|
||||
protected RealmModel realm;
|
||||
protected String[] referrer;
|
||||
protected List<Event> events;
|
||||
protected String stateChecker;
|
||||
protected String idTokenHint;
|
||||
protected List<UserSessionModel> sessions;
|
||||
protected boolean identityProviderEnabled;
|
||||
protected boolean eventsEnabled;
|
||||
protected boolean passwordUpdateSupported;
|
||||
protected boolean passwordSet;
|
||||
protected KeycloakSession session;
|
||||
protected FreeMarkerProvider freeMarker;
|
||||
protected HttpHeaders headers;
|
||||
protected Map<String, Object> attributes;
|
||||
|
||||
protected UriInfo uriInfo;
|
||||
|
||||
protected List<FormMessage> messages = null;
|
||||
protected MessageType messageType = MessageType.ERROR;
|
||||
private boolean authorizationSupported;
|
||||
|
||||
public FreeMarkerAccountProvider(KeycloakSession session) {
|
||||
this.session = session;
|
||||
this.freeMarker = session.getProvider(FreeMarkerProvider.class);
|
||||
}
|
||||
|
||||
public AccountProvider setUriInfo(UriInfo uriInfo) {
|
||||
this.uriInfo = uriInfo;
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public AccountProvider setHttpHeaders(HttpHeaders httpHeaders) {
|
||||
this.headers = httpHeaders;
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Response createResponse(AccountPages page) {
|
||||
Map<String, Object> attributes = new HashMap<>();
|
||||
|
||||
if (this.attributes != null) {
|
||||
attributes.putAll(this.attributes);
|
||||
}
|
||||
|
||||
Theme theme;
|
||||
try {
|
||||
theme = getTheme();
|
||||
} catch (IOException e) {
|
||||
logger.error("Failed to create theme", e);
|
||||
return Response.serverError().build();
|
||||
}
|
||||
|
||||
Locale locale = session.getContext().resolveLocale(user);
|
||||
Properties messagesBundle = handleThemeResources(theme, locale, attributes);
|
||||
|
||||
URI baseUri = uriInfo.getBaseUri();
|
||||
UriBuilder baseUriBuilder = uriInfo.getBaseUriBuilder();
|
||||
for (Map.Entry<String, List<String>> e : uriInfo.getQueryParameters().entrySet()) {
|
||||
baseUriBuilder.queryParam(e.getKey(), e.getValue().toArray());
|
||||
}
|
||||
URI baseQueryUri = baseUriBuilder.build();
|
||||
|
||||
if (stateChecker != null) {
|
||||
attributes.put("stateChecker", stateChecker);
|
||||
}
|
||||
|
||||
handleMessages(locale, messagesBundle, attributes);
|
||||
|
||||
if (referrer != null) {
|
||||
attributes.put("referrer", new ReferrerBean(referrer));
|
||||
}
|
||||
|
||||
if (realm != null) {
|
||||
attributes.put("realm", new RealmBean(realm));
|
||||
}
|
||||
|
||||
attributes.put(
|
||||
"url",
|
||||
new UrlBean(realm, theme, baseUri, baseQueryUri, uriInfo.getRequestUri(), idTokenHint));
|
||||
|
||||
if (realm.isInternationalizationEnabled()) {
|
||||
UriBuilder b = UriBuilder.fromUri(baseQueryUri).path(uriInfo.getPath());
|
||||
attributes.put("locale", new LocaleBean(realm, locale, b, messagesBundle));
|
||||
}
|
||||
|
||||
attributes.put(
|
||||
"features",
|
||||
new FeaturesBean(
|
||||
identityProviderEnabled,
|
||||
eventsEnabled,
|
||||
passwordUpdateSupported,
|
||||
authorizationSupported));
|
||||
attributes.put("account", new AccountBean(user, profileFormData));
|
||||
|
||||
switch (page) {
|
||||
case TOTP:
|
||||
attributes.put("totp", new TotpBean(session, realm, user, uriInfo.getRequestUriBuilder()));
|
||||
break;
|
||||
case FEDERATED_IDENTITY:
|
||||
attributes.put(
|
||||
"federatedIdentity",
|
||||
new AccountFederatedIdentityBean(
|
||||
session, realm, user, uriInfo.getBaseUri(), stateChecker));
|
||||
break;
|
||||
case LOG:
|
||||
attributes.put("log", new LogBean(events));
|
||||
break;
|
||||
case SESSIONS:
|
||||
attributes.put("sessions", new SessionsBean(realm, sessions));
|
||||
break;
|
||||
case APPLICATIONS:
|
||||
attributes.put("applications", new ApplicationsBean(session, realm, user));
|
||||
attributes.put("advancedMsg", new AdvancedMessageFormatterMethod(locale, messagesBundle));
|
||||
break;
|
||||
case PASSWORD:
|
||||
attributes.put("password", new PasswordBean(passwordSet));
|
||||
break;
|
||||
case RESOURCES:
|
||||
if (!realm.isUserManagedAccessAllowed()) {
|
||||
return Response.status(Status.FORBIDDEN).build();
|
||||
}
|
||||
attributes.put("authorization", new AuthorizationBean(session, realm, user, uriInfo));
|
||||
case RESOURCE_DETAIL:
|
||||
if (!realm.isUserManagedAccessAllowed()) {
|
||||
return Response.status(Status.FORBIDDEN).build();
|
||||
}
|
||||
attributes.put("authorization", new AuthorizationBean(session, realm, user, uriInfo));
|
||||
}
|
||||
|
||||
return processTemplate(theme, page, attributes, locale);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Theme used for page rendering.
|
||||
*
|
||||
* @return theme for page rendering, never null
|
||||
* @throws IOException in case of Theme loading problem
|
||||
*/
|
||||
protected Theme getTheme() throws IOException {
|
||||
return session.theme().getTheme(Theme.Type.ACCOUNT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load message bundle and place it into <code>msg</code> template attribute. Also load Theme
|
||||
* properties and place them into <code>properties</code> template attribute.
|
||||
*
|
||||
* @param theme actual Theme to load bundle from
|
||||
* @param locale to load bundle for
|
||||
* @param attributes template attributes to add resources to
|
||||
* @return message bundle for other use
|
||||
*/
|
||||
protected Properties handleThemeResources(
|
||||
Theme theme, Locale locale, Map<String, Object> attributes) {
|
||||
Properties messagesBundle = new Properties();
|
||||
try {
|
||||
if (!StringUtil.isNotBlank(realm.getDefaultLocale())) {
|
||||
messagesBundle.putAll(realm.getRealmLocalizationTextsByLocale(realm.getDefaultLocale()));
|
||||
}
|
||||
messagesBundle.putAll(theme.getMessages(locale));
|
||||
messagesBundle.putAll(realm.getRealmLocalizationTextsByLocale(locale.toLanguageTag()));
|
||||
attributes.put("msg", new MessageFormatterMethod(locale, messagesBundle));
|
||||
} catch (IOException e) {
|
||||
logger.warn("Failed to load messages", e);
|
||||
messagesBundle = new Properties();
|
||||
}
|
||||
try {
|
||||
attributes.put("properties", theme.getProperties());
|
||||
} catch (IOException e) {
|
||||
logger.warn("Failed to load properties", e);
|
||||
}
|
||||
return messagesBundle;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle messages to be shown on the page - set them to template attributes
|
||||
*
|
||||
* @param locale to be used for message text loading
|
||||
* @param messagesBundle to be used for message text loading
|
||||
* @param attributes template attributes to messages related info to
|
||||
* @see #messageType
|
||||
* @see #messages
|
||||
*/
|
||||
protected void handleMessages(
|
||||
Locale locale, Properties messagesBundle, Map<String, Object> attributes) {
|
||||
MessagesPerFieldBean messagesPerField = new MessagesPerFieldBean();
|
||||
if (messages != null) {
|
||||
MessageBean wholeMessage = new MessageBean(null, messageType);
|
||||
for (FormMessage message : this.messages) {
|
||||
String formattedMessageText = formatMessage(message, messagesBundle, locale);
|
||||
if (formattedMessageText != null) {
|
||||
wholeMessage.appendSummaryLine(formattedMessageText);
|
||||
messagesPerField.addMessage(message.getField(), formattedMessageText, messageType);
|
||||
}
|
||||
}
|
||||
attributes.put("message", wholeMessage);
|
||||
}
|
||||
attributes.put("messagesPerField", messagesPerField);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process FreeMarker template and prepare Response. Some fields are used for rendering also.
|
||||
*
|
||||
* @param theme to be used (provided by <code>getTheme()</code>)
|
||||
* @param page to be rendered
|
||||
* @param attributes pushed to the template
|
||||
* @param locale to be used
|
||||
* @return Response object to be returned to the browser, never null
|
||||
*/
|
||||
protected Response processTemplate(
|
||||
Theme theme, AccountPages page, Map<String, Object> attributes, Locale locale) {
|
||||
try {
|
||||
String result = freeMarker.processTemplate(attributes, Templates.getTemplate(page), theme);
|
||||
Response.ResponseBuilder builder =
|
||||
Response.status(status)
|
||||
.type(MediaType.TEXT_HTML_UTF_8_TYPE)
|
||||
.language(locale)
|
||||
.entity(result);
|
||||
builder.cacheControl(CacheControlUtil.noCache());
|
||||
return builder.build();
|
||||
} catch (FreeMarkerException e) {
|
||||
logger.error("Failed to process template", e);
|
||||
return Response.serverError().build();
|
||||
}
|
||||
}
|
||||
|
||||
public AccountProvider setPasswordSet(boolean passwordSet) {
|
||||
this.passwordSet = passwordSet;
|
||||
return this;
|
||||
}
|
||||
|
||||
protected void setMessage(MessageType type, String message, Object... parameters) {
|
||||
messageType = type;
|
||||
messages = new ArrayList<>();
|
||||
messages.add(new FormMessage(null, message, parameters));
|
||||
}
|
||||
|
||||
protected String formatMessage(FormMessage message, Properties messagesBundle, Locale locale) {
|
||||
if (message == null) return null;
|
||||
if (messagesBundle.containsKey(message.getMessage())) {
|
||||
return new MessageFormat(messagesBundle.getProperty(message.getMessage()), locale)
|
||||
.format(message.getParameters());
|
||||
} else {
|
||||
return message.getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public AccountProvider setErrors(Response.Status status, List<FormMessage> messages) {
|
||||
this.status = status;
|
||||
this.messageType = MessageType.ERROR;
|
||||
this.messages = new ArrayList<>(messages);
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public AccountProvider setError(Response.Status status, String message, Object... parameters) {
|
||||
this.status = status;
|
||||
setMessage(MessageType.ERROR, message, parameters);
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public AccountProvider setSuccess(String message, Object... parameters) {
|
||||
setMessage(MessageType.SUCCESS, message, parameters);
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public AccountProvider setWarning(String message, Object... parameters) {
|
||||
setMessage(MessageType.WARNING, message, parameters);
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public AccountProvider setUser(UserModel user) {
|
||||
this.user = user;
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public AccountProvider setProfileFormData(MultivaluedMap<String, String> formData) {
|
||||
this.profileFormData = formData;
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public AccountProvider setRealm(RealmModel realm) {
|
||||
this.realm = realm;
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public AccountProvider setReferrer(String[] referrer) {
|
||||
this.referrer = referrer;
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public AccountProvider setEvents(List<Event> events) {
|
||||
this.events = events;
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public AccountProvider setSessions(List<UserSessionModel> sessions) {
|
||||
this.sessions = sessions;
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public AccountProvider setStateChecker(String stateChecker) {
|
||||
this.stateChecker = stateChecker;
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public AccountProvider setIdTokenHint(String idTokenHint) {
|
||||
this.idTokenHint = idTokenHint;
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public AccountProvider setFeatures(
|
||||
boolean identityProviderEnabled,
|
||||
boolean eventsEnabled,
|
||||
boolean passwordUpdateSupported,
|
||||
boolean authorizationSupported) {
|
||||
this.identityProviderEnabled = identityProviderEnabled;
|
||||
this.eventsEnabled = eventsEnabled;
|
||||
this.passwordUpdateSupported = passwordUpdateSupported;
|
||||
this.authorizationSupported = authorizationSupported;
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public AccountProvider setAttribute(String key, String value) {
|
||||
if (attributes == null) {
|
||||
attributes = new HashMap<>();
|
||||
}
|
||||
attributes.put(key, value);
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {}
|
||||
}
|
@ -0,0 +1,51 @@
|
||||
/*
|
||||
* Copyright 2016 Red Hat, Inc. and/or its affiliates
|
||||
* and other contributors as indicated by the @author tags.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.keycloak.forms.account.freemarker;
|
||||
|
||||
import com.google.auto.service.AutoService;
|
||||
import org.keycloak.Config;
|
||||
import org.keycloak.forms.account.AccountProvider;
|
||||
import org.keycloak.forms.account.AccountProviderFactory;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.KeycloakSessionFactory;
|
||||
|
||||
/**
|
||||
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
|
||||
*/
|
||||
@AutoService(AccountProviderFactory.class)
|
||||
public class FreeMarkerAccountProviderFactory implements AccountProviderFactory {
|
||||
|
||||
@Override
|
||||
public AccountProvider create(KeycloakSession session) {
|
||||
return new FreeMarkerAccountProvider(session);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void init(Config.Scope config) {}
|
||||
|
||||
@Override
|
||||
public void postInit(KeycloakSessionFactory factory) {}
|
||||
|
||||
@Override
|
||||
public void close() {}
|
||||
|
||||
@Override
|
||||
public String getId() {
|
||||
return "freemarker";
|
||||
}
|
||||
}
|
@ -0,0 +1,51 @@
|
||||
/*
|
||||
* Copyright 2016 Red Hat, Inc. and/or its affiliates
|
||||
* and other contributors as indicated by the @author tags.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.keycloak.forms.account.freemarker;
|
||||
|
||||
import org.keycloak.forms.account.AccountPages;
|
||||
|
||||
/**
|
||||
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
|
||||
*/
|
||||
public class Templates {
|
||||
|
||||
public static String getTemplate(AccountPages page) {
|
||||
switch (page) {
|
||||
case ACCOUNT:
|
||||
return "account.ftl";
|
||||
case PASSWORD:
|
||||
return "password.ftl";
|
||||
case TOTP:
|
||||
return "totp.ftl";
|
||||
case FEDERATED_IDENTITY:
|
||||
return "federatedIdentity.ftl";
|
||||
case LOG:
|
||||
return "log.ftl";
|
||||
case SESSIONS:
|
||||
return "sessions.ftl";
|
||||
case APPLICATIONS:
|
||||
return "applications.ftl";
|
||||
case RESOURCES:
|
||||
return "resources.ftl";
|
||||
case RESOURCE_DETAIL:
|
||||
return "resource-detail.ftl";
|
||||
default:
|
||||
throw new IllegalArgumentException();
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,91 @@
|
||||
/*
|
||||
* Copyright 2016 Red Hat, Inc. and/or its affiliates
|
||||
* and other contributors as indicated by the @author tags.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.keycloak.forms.account.freemarker.model;
|
||||
|
||||
import jakarta.ws.rs.core.MultivaluedMap;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.jboss.logging.Logger;
|
||||
import org.keycloak.models.Constants;
|
||||
import org.keycloak.models.UserModel;
|
||||
|
||||
/**
|
||||
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
|
||||
*/
|
||||
public class AccountBean {
|
||||
|
||||
private static final Logger logger = Logger.getLogger(AccountBean.class);
|
||||
|
||||
private final UserModel user;
|
||||
private final MultivaluedMap<String, String> profileFormData;
|
||||
|
||||
// TODO: More proper multi-value attribute support
|
||||
private final Map<String, String> attributes = new HashMap<>();
|
||||
|
||||
public AccountBean(UserModel user, MultivaluedMap<String, String> profileFormData) {
|
||||
this.user = user;
|
||||
this.profileFormData = profileFormData;
|
||||
|
||||
for (Map.Entry<String, List<String>> attr : user.getAttributes().entrySet()) {
|
||||
List<String> attrValue = attr.getValue();
|
||||
if (attrValue.size() > 0) {
|
||||
attributes.put(attr.getKey(), attrValue.get(0));
|
||||
}
|
||||
|
||||
if (attrValue.size() > 1) {
|
||||
logger.warnf(
|
||||
"There are more values for attribute '%s' of user '%s' . Will display just first value",
|
||||
attr.getKey(), user.getUsername());
|
||||
}
|
||||
}
|
||||
|
||||
if (profileFormData != null) {
|
||||
for (String key : profileFormData.keySet()) {
|
||||
if (key.startsWith(Constants.USER_ATTRIBUTES_PREFIX)) {
|
||||
String attribute = key.substring(Constants.USER_ATTRIBUTES_PREFIX.length());
|
||||
attributes.put(attribute, profileFormData.getFirst(key));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public String getFirstName() {
|
||||
return profileFormData != null ? profileFormData.getFirst("firstName") : user.getFirstName();
|
||||
}
|
||||
|
||||
public String getLastName() {
|
||||
return profileFormData != null ? profileFormData.getFirst("lastName") : user.getLastName();
|
||||
}
|
||||
|
||||
public String getUsername() {
|
||||
if (profileFormData != null && profileFormData.containsKey("username")) {
|
||||
return profileFormData.getFirst("username");
|
||||
} else {
|
||||
return user.getUsername();
|
||||
}
|
||||
}
|
||||
|
||||
public String getEmail() {
|
||||
return profileFormData != null ? profileFormData.getFirst("email") : user.getEmail();
|
||||
}
|
||||
|
||||
public Map<String, String> getAttributes() {
|
||||
return attributes;
|
||||
}
|
||||
}
|
@ -0,0 +1,157 @@
|
||||
/*
|
||||
* Copyright 2016 Red Hat, Inc. and/or its affiliates
|
||||
* and other contributors as indicated by the @author tags.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.keycloak.forms.account.freemarker.model;
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
import org.keycloak.models.FederatedIdentityModel;
|
||||
import org.keycloak.models.IdentityProviderModel;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.OrderedModel;
|
||||
import org.keycloak.models.RealmModel;
|
||||
import org.keycloak.models.UserModel;
|
||||
import org.keycloak.models.utils.KeycloakModelUtils;
|
||||
import org.keycloak.services.resources.account.AccountFormService;
|
||||
|
||||
/**
|
||||
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
|
||||
* @author <a href="mailto:velias@redhat.com">Vlastimil Elias</a>
|
||||
*/
|
||||
public class AccountFederatedIdentityBean {
|
||||
|
||||
private static OrderedModel.OrderedModelComparator<FederatedIdentityEntry>
|
||||
IDP_COMPARATOR_INSTANCE = new OrderedModel.OrderedModelComparator<>();
|
||||
|
||||
private final List<FederatedIdentityEntry> identities;
|
||||
private final boolean removeLinkPossible;
|
||||
private final KeycloakSession session;
|
||||
|
||||
public AccountFederatedIdentityBean(
|
||||
KeycloakSession session, RealmModel realm, UserModel user, URI baseUri, String stateChecker) {
|
||||
this.session = session;
|
||||
|
||||
AtomicInteger availableIdentities = new AtomicInteger(0);
|
||||
this.identities =
|
||||
realm
|
||||
.getIdentityProvidersStream()
|
||||
.filter(IdentityProviderModel::isEnabled)
|
||||
.map(
|
||||
provider -> {
|
||||
String providerId = provider.getAlias();
|
||||
|
||||
FederatedIdentityModel identity =
|
||||
getIdentity(
|
||||
session.users().getFederatedIdentitiesStream(realm, user), providerId);
|
||||
|
||||
if (identity != null) {
|
||||
availableIdentities.getAndIncrement();
|
||||
}
|
||||
|
||||
String displayName =
|
||||
KeycloakModelUtils.getIdentityProviderDisplayName(session, provider);
|
||||
return new FederatedIdentityEntry(
|
||||
identity,
|
||||
displayName,
|
||||
provider.getAlias(),
|
||||
provider.getAlias(),
|
||||
provider.getConfig() != null ? provider.getConfig().get("guiOrder") : null);
|
||||
})
|
||||
.sorted(IDP_COMPARATOR_INSTANCE)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
// Removing last social provider is not possible if you don't have other possibility to
|
||||
// authenticate
|
||||
this.removeLinkPossible =
|
||||
availableIdentities.get() > 1
|
||||
|| user.getFederationLink() != null
|
||||
|| AccountFormService.isPasswordSet(session, realm, user);
|
||||
}
|
||||
|
||||
private FederatedIdentityModel getIdentity(
|
||||
Stream<FederatedIdentityModel> identities, String providerId) {
|
||||
return identities
|
||||
.filter(
|
||||
federatedIdentityModel ->
|
||||
Objects.equals(federatedIdentityModel.getIdentityProvider(), providerId))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
public List<FederatedIdentityEntry> getIdentities() {
|
||||
return identities;
|
||||
}
|
||||
|
||||
public boolean isRemoveLinkPossible() {
|
||||
return removeLinkPossible;
|
||||
}
|
||||
|
||||
public static class FederatedIdentityEntry implements OrderedModel {
|
||||
|
||||
private FederatedIdentityModel federatedIdentityModel;
|
||||
private final String providerId;
|
||||
private final String providerName;
|
||||
private final String guiOrder;
|
||||
private final String displayName;
|
||||
|
||||
public FederatedIdentityEntry(
|
||||
FederatedIdentityModel federatedIdentityModel,
|
||||
String displayName,
|
||||
String providerId,
|
||||
String providerName,
|
||||
String guiOrder) {
|
||||
this.federatedIdentityModel = federatedIdentityModel;
|
||||
this.displayName = displayName;
|
||||
this.providerId = providerId;
|
||||
this.providerName = providerName;
|
||||
this.guiOrder = guiOrder;
|
||||
}
|
||||
|
||||
public String getProviderId() {
|
||||
return providerId;
|
||||
}
|
||||
|
||||
public String getProviderName() {
|
||||
return providerName;
|
||||
}
|
||||
|
||||
public String getUserId() {
|
||||
return federatedIdentityModel != null ? federatedIdentityModel.getUserId() : null;
|
||||
}
|
||||
|
||||
public String getUserName() {
|
||||
return federatedIdentityModel != null ? federatedIdentityModel.getUserName() : null;
|
||||
}
|
||||
|
||||
public boolean isConnected() {
|
||||
return federatedIdentityModel != null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getGuiOrder() {
|
||||
return guiOrder;
|
||||
}
|
||||
|
||||
public String getDisplayName() {
|
||||
return displayName;
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,258 @@
|
||||
/*
|
||||
* Copyright 2016 Red Hat, Inc. and/or its affiliates
|
||||
* and other contributors as indicated by the @author tags.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.keycloak.forms.account.freemarker.model;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.function.Predicate;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
import org.keycloak.common.util.MultivaluedHashMap;
|
||||
import org.keycloak.models.ClientModel;
|
||||
import org.keycloak.models.ClientScopeModel;
|
||||
import org.keycloak.models.Constants;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.OrderedModel;
|
||||
import org.keycloak.models.RealmModel;
|
||||
import org.keycloak.models.RoleModel;
|
||||
import org.keycloak.models.UserConsentModel;
|
||||
import org.keycloak.models.UserModel;
|
||||
import org.keycloak.protocol.oidc.TokenManager;
|
||||
import org.keycloak.services.managers.UserSessionManager;
|
||||
import org.keycloak.services.resources.admin.permissions.AdminPermissions;
|
||||
import org.keycloak.services.util.ResolveRelative;
|
||||
import org.keycloak.storage.StorageId;
|
||||
|
||||
/**
|
||||
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
|
||||
*/
|
||||
public class ApplicationsBean {
|
||||
|
||||
private List<ApplicationEntry> applications = new LinkedList<>();
|
||||
|
||||
public ApplicationsBean(KeycloakSession session, RealmModel realm, UserModel user) {
|
||||
Set<ClientModel> offlineClients =
|
||||
new UserSessionManager(session).findClientsWithOfflineToken(realm, user);
|
||||
|
||||
this.applications =
|
||||
this.getApplications(session, realm, user)
|
||||
.filter(
|
||||
client ->
|
||||
!isAdminClient(client)
|
||||
|| AdminPermissions.realms(session, realm, user).isAdmin())
|
||||
.map(client -> toApplicationEntry(session, realm, user, client, offlineClients))
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
public static boolean isAdminClient(ClientModel client) {
|
||||
return client.getClientId().equals(Constants.ADMIN_CLI_CLIENT_ID)
|
||||
|| client.getClientId().equals(Constants.ADMIN_CONSOLE_CLIENT_ID);
|
||||
}
|
||||
|
||||
private Stream<ClientModel> getApplications(
|
||||
KeycloakSession session, RealmModel realm, UserModel user) {
|
||||
Predicate<ClientModel> bearerOnly = ClientModel::isBearerOnly;
|
||||
Stream<ClientModel> clients = realm.getClientsStream().filter(bearerOnly.negate());
|
||||
|
||||
Predicate<ClientModel> isLocal = client -> new StorageId(client.getId()).isLocal();
|
||||
return Stream.concat(
|
||||
clients,
|
||||
session
|
||||
.users()
|
||||
.getConsentsStream(realm, user.getId())
|
||||
.map(UserConsentModel::getClient)
|
||||
.filter(isLocal.negate()))
|
||||
.distinct();
|
||||
}
|
||||
|
||||
private void processRoles(
|
||||
Set<RoleModel> inputRoles,
|
||||
List<RoleModel> realmRoles,
|
||||
MultivaluedHashMap<String, ClientRoleEntry> clientRoles) {
|
||||
for (RoleModel role : inputRoles) {
|
||||
if (role.getContainer() instanceof RealmModel) {
|
||||
realmRoles.add(role);
|
||||
} else {
|
||||
ClientModel currentClient = (ClientModel) role.getContainer();
|
||||
ClientRoleEntry clientRole =
|
||||
new ClientRoleEntry(
|
||||
currentClient.getClientId(),
|
||||
currentClient.getName(),
|
||||
role.getName(),
|
||||
role.getDescription());
|
||||
clientRoles.add(currentClient.getClientId(), clientRole);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public List<ApplicationEntry> getApplications() {
|
||||
return applications;
|
||||
}
|
||||
|
||||
public static class ApplicationEntry {
|
||||
|
||||
private KeycloakSession session;
|
||||
private final List<RoleModel> realmRolesAvailable;
|
||||
private final MultivaluedHashMap<String, ClientRoleEntry> resourceRolesAvailable;
|
||||
private final ClientModel client;
|
||||
private final List<String> clientScopesGranted;
|
||||
private final List<String> additionalGrants;
|
||||
|
||||
public ApplicationEntry(
|
||||
KeycloakSession session,
|
||||
List<RoleModel> realmRolesAvailable,
|
||||
MultivaluedHashMap<String, ClientRoleEntry> resourceRolesAvailable,
|
||||
ClientModel client,
|
||||
List<String> clientScopesGranted,
|
||||
List<String> additionalGrants) {
|
||||
this.session = session;
|
||||
this.realmRolesAvailable = realmRolesAvailable;
|
||||
this.resourceRolesAvailable = resourceRolesAvailable;
|
||||
this.client = client;
|
||||
this.clientScopesGranted = clientScopesGranted;
|
||||
this.additionalGrants = additionalGrants;
|
||||
}
|
||||
|
||||
public List<RoleModel> getRealmRolesAvailable() {
|
||||
return realmRolesAvailable;
|
||||
}
|
||||
|
||||
public MultivaluedHashMap<String, ClientRoleEntry> getResourceRolesAvailable() {
|
||||
return resourceRolesAvailable;
|
||||
}
|
||||
|
||||
public List<String> getClientScopesGranted() {
|
||||
return clientScopesGranted;
|
||||
}
|
||||
|
||||
public String getEffectiveUrl() {
|
||||
return ResolveRelative.resolveRelativeUri(
|
||||
session, getClient().getRootUrl(), getClient().getBaseUrl());
|
||||
}
|
||||
|
||||
public ClientModel getClient() {
|
||||
return client;
|
||||
}
|
||||
|
||||
public List<String> getAdditionalGrants() {
|
||||
return additionalGrants;
|
||||
}
|
||||
}
|
||||
|
||||
// Same class used in OAuthGrantBean as well. Maybe should be merged into common-freemarker...
|
||||
public static class ClientRoleEntry {
|
||||
|
||||
private final String clientId;
|
||||
private final String clientName;
|
||||
private final String roleName;
|
||||
private final String roleDescription;
|
||||
|
||||
public ClientRoleEntry(
|
||||
String clientId, String clientName, String roleName, String roleDescription) {
|
||||
this.clientId = clientId;
|
||||
this.clientName = clientName;
|
||||
this.roleName = roleName;
|
||||
this.roleDescription = roleDescription;
|
||||
}
|
||||
|
||||
public String getClientId() {
|
||||
return clientId;
|
||||
}
|
||||
|
||||
public String getClientName() {
|
||||
return clientName;
|
||||
}
|
||||
|
||||
public String getRoleName() {
|
||||
return roleName;
|
||||
}
|
||||
|
||||
public String getRoleDescription() {
|
||||
return roleDescription;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs a {@link ApplicationEntry} from the specified parameters.
|
||||
*
|
||||
* @param session a reference to the {@code Keycloak} session.
|
||||
* @param realm a reference to the realm.
|
||||
* @param user a reference to the user.
|
||||
* @param client a reference to the client that contains the applications.
|
||||
* @param offlineClients a {@link Set} containing the offline clients.
|
||||
* @return the constructed {@link ApplicationEntry} instance or {@code null} if the user can't
|
||||
* access the applications in the specified client.
|
||||
*/
|
||||
private ApplicationEntry toApplicationEntry(
|
||||
final KeycloakSession session,
|
||||
final RealmModel realm,
|
||||
final UserModel user,
|
||||
final ClientModel client,
|
||||
final Set<ClientModel> offlineClients) {
|
||||
|
||||
// Construct scope parameter with all optional scopes to see all potentially available roles
|
||||
Stream<ClientScopeModel> allClientScopes =
|
||||
Stream.concat(
|
||||
client.getClientScopes(true).values().stream(),
|
||||
client.getClientScopes(false).values().stream());
|
||||
allClientScopes = Stream.concat(allClientScopes, Stream.of(client)).distinct();
|
||||
|
||||
Set<RoleModel> availableRoles = TokenManager.getAccess(user, client, allClientScopes);
|
||||
|
||||
// Don't show applications, which user doesn't have access into (any available roles)
|
||||
// unless this is can be changed by approving/revoking consent
|
||||
if (!isAdminClient(client) && availableRoles.isEmpty() && !client.isConsentRequired()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
List<RoleModel> realmRolesAvailable = new LinkedList<>();
|
||||
MultivaluedHashMap<String, ClientRoleEntry> resourceRolesAvailable = new MultivaluedHashMap<>();
|
||||
processRoles(availableRoles, realmRolesAvailable, resourceRolesAvailable);
|
||||
|
||||
List<ClientScopeModel> orderedScopes = new LinkedList<>();
|
||||
if (client.isConsentRequired()) {
|
||||
UserConsentModel consent =
|
||||
session.users().getConsentByClient(realm, user.getId(), client.getId());
|
||||
|
||||
if (consent != null) {
|
||||
orderedScopes.addAll(consent.getGrantedClientScopes());
|
||||
}
|
||||
}
|
||||
List<String> clientScopesGranted =
|
||||
orderedScopes.stream()
|
||||
.sorted(OrderedModel.OrderedModelComparator.getInstance())
|
||||
.map(ClientScopeModel::getConsentScreenText)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
List<String> additionalGrants = new ArrayList<>();
|
||||
if (offlineClients.contains(client)) {
|
||||
additionalGrants.add("${offlineToken}");
|
||||
}
|
||||
return new ApplicationEntry(
|
||||
session,
|
||||
realmRolesAvailable,
|
||||
resourceRolesAvailable,
|
||||
client,
|
||||
clientScopesGranted,
|
||||
additionalGrants);
|
||||
}
|
||||
}
|
@ -0,0 +1,515 @@
|
||||
/*
|
||||
* Copyright 2022 Red Hat, Inc. and/or its affiliates
|
||||
* and other contributors as indicated by the @author tags.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.keycloak.forms.account.freemarker.model;
|
||||
|
||||
import jakarta.ws.rs.core.UriInfo;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.Date;
|
||||
import java.util.EnumMap;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import org.keycloak.authorization.AuthorizationProvider;
|
||||
import org.keycloak.authorization.model.PermissionTicket;
|
||||
import org.keycloak.authorization.model.Policy;
|
||||
import org.keycloak.authorization.model.Resource;
|
||||
import org.keycloak.authorization.model.ResourceServer;
|
||||
import org.keycloak.authorization.model.Scope;
|
||||
import org.keycloak.authorization.store.PermissionTicketStore;
|
||||
import org.keycloak.common.util.Time;
|
||||
import org.keycloak.models.ClientModel;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.RealmModel;
|
||||
import org.keycloak.models.UserModel;
|
||||
import org.keycloak.models.utils.ModelToRepresentation;
|
||||
import org.keycloak.representations.idm.authorization.ScopeRepresentation;
|
||||
import org.keycloak.services.util.ResolveRelative;
|
||||
|
||||
/**
|
||||
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
|
||||
*/
|
||||
public class AuthorizationBean {
|
||||
|
||||
private final KeycloakSession session;
|
||||
private final RealmModel realm;
|
||||
private final UserModel user;
|
||||
private final AuthorizationProvider authorization;
|
||||
private final UriInfo uriInfo;
|
||||
private ResourceBean resource;
|
||||
private List<ResourceBean> resources;
|
||||
private Collection<ResourceBean> userSharedResources;
|
||||
private Collection<ResourceBean> requestsWaitingPermission;
|
||||
private Collection<ResourceBean> resourcesWaitingOthersApproval;
|
||||
|
||||
public AuthorizationBean(
|
||||
KeycloakSession session, RealmModel realm, UserModel user, UriInfo uriInfo) {
|
||||
this.session = session;
|
||||
this.realm = realm;
|
||||
this.user = user;
|
||||
this.uriInfo = uriInfo;
|
||||
authorization = session.getProvider(AuthorizationProvider.class);
|
||||
List<String> pathParameters = uriInfo.getPathParameters().get("resource_id");
|
||||
|
||||
if (pathParameters != null && !pathParameters.isEmpty()) {
|
||||
Resource resource =
|
||||
authorization
|
||||
.getStoreFactory()
|
||||
.getResourceStore()
|
||||
.findById(realm, null, pathParameters.get(0));
|
||||
|
||||
if (resource != null && !resource.getOwner().equals(user.getId())) {
|
||||
throw new RuntimeException(
|
||||
"User [" + user.getUsername() + "] can not access resource [" + resource.getId() + "]");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public Collection<ResourceBean> getResourcesWaitingOthersApproval() {
|
||||
if (resourcesWaitingOthersApproval == null) {
|
||||
Map<PermissionTicket.FilterOption, String> filters =
|
||||
new EnumMap<>(PermissionTicket.FilterOption.class);
|
||||
|
||||
filters.put(PermissionTicket.FilterOption.REQUESTER, user.getId());
|
||||
filters.put(PermissionTicket.FilterOption.GRANTED, Boolean.FALSE.toString());
|
||||
|
||||
resourcesWaitingOthersApproval = toResourceRepresentation(findPermissions(filters));
|
||||
}
|
||||
|
||||
return resourcesWaitingOthersApproval;
|
||||
}
|
||||
|
||||
public Collection<ResourceBean> getResourcesWaitingApproval() {
|
||||
if (requestsWaitingPermission == null) {
|
||||
Map<PermissionTicket.FilterOption, String> filters =
|
||||
new EnumMap<>(PermissionTicket.FilterOption.class);
|
||||
|
||||
filters.put(PermissionTicket.FilterOption.OWNER, user.getId());
|
||||
filters.put(PermissionTicket.FilterOption.GRANTED, Boolean.FALSE.toString());
|
||||
|
||||
requestsWaitingPermission = toResourceRepresentation(findPermissions(filters));
|
||||
}
|
||||
|
||||
return requestsWaitingPermission;
|
||||
}
|
||||
|
||||
public List<ResourceBean> getResources() {
|
||||
if (resources == null) {
|
||||
resources =
|
||||
authorization
|
||||
.getStoreFactory()
|
||||
.getResourceStore()
|
||||
.findByOwner(realm, null, user.getId())
|
||||
.stream()
|
||||
.filter(Resource::isOwnerManagedAccess)
|
||||
.map(ResourceBean::new)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
return resources;
|
||||
}
|
||||
|
||||
public Collection<ResourceBean> getSharedResources() {
|
||||
if (userSharedResources == null) {
|
||||
Map<PermissionTicket.FilterOption, String> filters =
|
||||
new EnumMap<>(PermissionTicket.FilterOption.class);
|
||||
|
||||
filters.put(PermissionTicket.FilterOption.REQUESTER, user.getId());
|
||||
filters.put(PermissionTicket.FilterOption.GRANTED, Boolean.TRUE.toString());
|
||||
|
||||
PermissionTicketStore ticketStore =
|
||||
authorization.getStoreFactory().getPermissionTicketStore();
|
||||
|
||||
userSharedResources =
|
||||
toResourceRepresentation(ticketStore.find(realm, null, filters, null, null));
|
||||
}
|
||||
return userSharedResources;
|
||||
}
|
||||
|
||||
public ResourceBean getResource() {
|
||||
if (resource == null) {
|
||||
String resourceId = uriInfo.getPathParameters().getFirst("resource_id");
|
||||
|
||||
if (resourceId != null) {
|
||||
resource = getResource(resourceId);
|
||||
}
|
||||
}
|
||||
|
||||
return resource;
|
||||
}
|
||||
|
||||
private ResourceBean getResource(String id) {
|
||||
return new ResourceBean(
|
||||
authorization.getStoreFactory().getResourceStore().findById(realm, null, id));
|
||||
}
|
||||
|
||||
public static class RequesterBean {
|
||||
|
||||
private final Long createdTimestamp;
|
||||
private final Long grantedTimestamp;
|
||||
private UserModel requester;
|
||||
private List<PermissionScopeBean> scopes = new ArrayList<>();
|
||||
private boolean granted;
|
||||
|
||||
public RequesterBean(PermissionTicket ticket, AuthorizationProvider authorization) {
|
||||
this.requester =
|
||||
authorization
|
||||
.getKeycloakSession()
|
||||
.users()
|
||||
.getUserById(authorization.getRealm(), ticket.getRequester());
|
||||
granted = ticket.isGranted();
|
||||
createdTimestamp = ticket.getCreatedTimestamp();
|
||||
grantedTimestamp = ticket.getGrantedTimestamp();
|
||||
}
|
||||
|
||||
public UserModel getRequester() {
|
||||
return requester;
|
||||
}
|
||||
|
||||
public List<PermissionScopeBean> getScopes() {
|
||||
return scopes;
|
||||
}
|
||||
|
||||
private void addScope(PermissionTicket ticket) {
|
||||
if (ticket != null) {
|
||||
scopes.add(new PermissionScopeBean(ticket));
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isGranted() {
|
||||
return (granted && scopes.isEmpty())
|
||||
|| scopes.stream().filter(permissionScopeBean -> permissionScopeBean.isGranted()).count()
|
||||
> 0;
|
||||
}
|
||||
|
||||
public Date getCreatedDate() {
|
||||
return Time.toDate(createdTimestamp);
|
||||
}
|
||||
|
||||
public Date getGrantedDate() {
|
||||
if (grantedTimestamp == null) {
|
||||
PermissionScopeBean permission =
|
||||
scopes.stream()
|
||||
.filter(permissionScopeBean -> permissionScopeBean.isGranted())
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
|
||||
if (permission == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return permission.getGrantedDate();
|
||||
}
|
||||
return Time.toDate(grantedTimestamp);
|
||||
}
|
||||
}
|
||||
|
||||
public static class PermissionScopeBean {
|
||||
|
||||
private final Scope scope;
|
||||
private final PermissionTicket ticket;
|
||||
|
||||
public PermissionScopeBean(PermissionTicket ticket) {
|
||||
this.ticket = ticket;
|
||||
scope = ticket.getScope();
|
||||
}
|
||||
|
||||
public String getId() {
|
||||
return ticket.getId();
|
||||
}
|
||||
|
||||
public Scope getScope() {
|
||||
return scope;
|
||||
}
|
||||
|
||||
public boolean isGranted() {
|
||||
return ticket.isGranted();
|
||||
}
|
||||
|
||||
private Date getGrantedDate() {
|
||||
if (isGranted()) {
|
||||
return Time.toDate(ticket.getGrantedTimestamp());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public class ResourceBean {
|
||||
|
||||
private final ResourceServerBean resourceServer;
|
||||
private final String ownerName;
|
||||
private final UserModel userOwner;
|
||||
private ClientModel clientOwner;
|
||||
private Resource resource;
|
||||
private Map<String, RequesterBean> permissions = new HashMap<>();
|
||||
private Collection<RequesterBean> shares;
|
||||
|
||||
public ResourceBean(Resource resource) {
|
||||
RealmModel realm = authorization.getRealm();
|
||||
ResourceServer resourceServerModel = resource.getResourceServer();
|
||||
resourceServer =
|
||||
new ResourceServerBean(
|
||||
realm.getClientById(resourceServerModel.getClientId()), resourceServerModel);
|
||||
this.resource = resource;
|
||||
userOwner =
|
||||
authorization.getKeycloakSession().users().getUserById(realm, resource.getOwner());
|
||||
if (userOwner == null) {
|
||||
clientOwner = realm.getClientById(resource.getOwner());
|
||||
ownerName = clientOwner.getClientId();
|
||||
} else if (userOwner.getEmail() != null) {
|
||||
ownerName = userOwner.getEmail();
|
||||
} else {
|
||||
ownerName = userOwner.getUsername();
|
||||
}
|
||||
}
|
||||
|
||||
public String getId() {
|
||||
return resource.getId();
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return resource.getName();
|
||||
}
|
||||
|
||||
public String getDisplayName() {
|
||||
return resource.getDisplayName();
|
||||
}
|
||||
|
||||
public String getIconUri() {
|
||||
return resource.getIconUri();
|
||||
}
|
||||
|
||||
public String getOwnerName() {
|
||||
return ownerName;
|
||||
}
|
||||
|
||||
public UserModel getUserOwner() {
|
||||
return userOwner;
|
||||
}
|
||||
|
||||
public ClientModel getClientOwner() {
|
||||
return clientOwner;
|
||||
}
|
||||
|
||||
public List<ScopeRepresentation> getScopes() {
|
||||
return resource.getScopes().stream()
|
||||
.map(ModelToRepresentation::toRepresentation)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
public Collection<RequesterBean> getShares() {
|
||||
if (shares == null) {
|
||||
Map<PermissionTicket.FilterOption, String> filters =
|
||||
new EnumMap<>(PermissionTicket.FilterOption.class);
|
||||
|
||||
filters.put(PermissionTicket.FilterOption.RESOURCE_ID, this.resource.getId());
|
||||
filters.put(PermissionTicket.FilterOption.GRANTED, Boolean.TRUE.toString());
|
||||
|
||||
shares = toPermissionRepresentation(findPermissions(filters));
|
||||
}
|
||||
|
||||
return shares;
|
||||
}
|
||||
|
||||
public Collection<ManagedPermissionBean> getPolicies() {
|
||||
ResourceServer resourceServer = getResourceServer().getResourceServerModel();
|
||||
RealmModel realm = resourceServer.getRealm();
|
||||
Map<Policy.FilterOption, String[]> filters = new EnumMap<>(Policy.FilterOption.class);
|
||||
|
||||
filters.put(Policy.FilterOption.TYPE, new String[] {"uma"});
|
||||
filters.put(Policy.FilterOption.RESOURCE_ID, new String[] {this.resource.getId()});
|
||||
if (getUserOwner() != null) {
|
||||
filters.put(Policy.FilterOption.OWNER, new String[] {getUserOwner().getId()});
|
||||
} else {
|
||||
filters.put(Policy.FilterOption.OWNER, new String[] {getClientOwner().getId()});
|
||||
}
|
||||
|
||||
List<Policy> policies =
|
||||
authorization
|
||||
.getStoreFactory()
|
||||
.getPolicyStore()
|
||||
.find(realm, resourceServer, filters, null, null);
|
||||
|
||||
if (policies.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
return policies.stream()
|
||||
.filter(
|
||||
policy -> {
|
||||
Map<PermissionTicket.FilterOption, String> filters1 =
|
||||
new EnumMap<>(PermissionTicket.FilterOption.class);
|
||||
|
||||
filters1.put(PermissionTicket.FilterOption.POLICY_ID, policy.getId());
|
||||
|
||||
return authorization
|
||||
.getStoreFactory()
|
||||
.getPermissionTicketStore()
|
||||
.find(realm, resourceServer, filters1, -1, 1)
|
||||
.isEmpty();
|
||||
})
|
||||
.map(ManagedPermissionBean::new)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
public ResourceServerBean getResourceServer() {
|
||||
return resourceServer;
|
||||
}
|
||||
|
||||
public Collection<RequesterBean> getPermissions() {
|
||||
return permissions.values();
|
||||
}
|
||||
|
||||
private void addPermission(PermissionTicket ticket, AuthorizationProvider authorization) {
|
||||
permissions
|
||||
.computeIfAbsent(ticket.getRequester(), key -> new RequesterBean(ticket, authorization))
|
||||
.addScope(ticket);
|
||||
}
|
||||
}
|
||||
|
||||
private Collection<RequesterBean> toPermissionRepresentation(
|
||||
List<PermissionTicket> permissionRequests) {
|
||||
Map<String, RequesterBean> requests = new HashMap<>();
|
||||
|
||||
for (PermissionTicket ticket : permissionRequests) {
|
||||
Resource resource = ticket.getResource();
|
||||
|
||||
if (!resource.isOwnerManagedAccess()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
requests
|
||||
.computeIfAbsent(
|
||||
ticket.getRequester(), resourceId -> new RequesterBean(ticket, authorization))
|
||||
.addScope(ticket);
|
||||
}
|
||||
|
||||
return requests.values();
|
||||
}
|
||||
|
||||
private Collection<ResourceBean> toResourceRepresentation(List<PermissionTicket> tickets) {
|
||||
Map<String, ResourceBean> requests = new HashMap<>();
|
||||
|
||||
for (PermissionTicket ticket : tickets) {
|
||||
Resource resource = ticket.getResource();
|
||||
|
||||
if (!resource.isOwnerManagedAccess()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
requests
|
||||
.computeIfAbsent(resource.getId(), resourceId -> getResource(resourceId))
|
||||
.addPermission(ticket, authorization);
|
||||
}
|
||||
|
||||
return requests.values();
|
||||
}
|
||||
|
||||
private List<PermissionTicket> findPermissions(
|
||||
Map<PermissionTicket.FilterOption, String> filters) {
|
||||
return authorization
|
||||
.getStoreFactory()
|
||||
.getPermissionTicketStore()
|
||||
.find(realm, null, filters, null, null);
|
||||
}
|
||||
|
||||
public class ResourceServerBean {
|
||||
|
||||
private ClientModel clientModel;
|
||||
private ResourceServer resourceServer;
|
||||
|
||||
public ResourceServerBean(ClientModel clientModel, ResourceServer resourceServer) {
|
||||
this.clientModel = clientModel;
|
||||
this.resourceServer = resourceServer;
|
||||
}
|
||||
|
||||
public String getId() {
|
||||
return resourceServer.getId();
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
String name = clientModel.getName();
|
||||
|
||||
if (name != null) {
|
||||
return name;
|
||||
}
|
||||
|
||||
return clientModel.getClientId();
|
||||
}
|
||||
|
||||
public String getClientId() {
|
||||
return clientModel.getClientId();
|
||||
}
|
||||
|
||||
public String getRedirectUri() {
|
||||
Set<String> redirectUris = clientModel.getRedirectUris();
|
||||
|
||||
if (redirectUris.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return redirectUris.iterator().next();
|
||||
}
|
||||
|
||||
public String getBaseUri() {
|
||||
return ResolveRelative.resolveRelativeUri(
|
||||
session, clientModel.getRootUrl(), clientModel.getBaseUrl());
|
||||
}
|
||||
|
||||
public ResourceServer getResourceServerModel() {
|
||||
return resourceServer;
|
||||
}
|
||||
}
|
||||
|
||||
public class ManagedPermissionBean {
|
||||
|
||||
private final Policy policy;
|
||||
private List<ManagedPermissionBean> policies;
|
||||
|
||||
public ManagedPermissionBean(Policy policy) {
|
||||
this.policy = policy;
|
||||
}
|
||||
|
||||
public String getId() {
|
||||
return policy.getId();
|
||||
}
|
||||
|
||||
public Collection<ScopeRepresentation> getScopes() {
|
||||
return policy.getScopes().stream()
|
||||
.map(ModelToRepresentation::toRepresentation)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
public String getDescription() {
|
||||
return this.policy.getDescription();
|
||||
}
|
||||
|
||||
public Collection<ManagedPermissionBean> getPolicies() {
|
||||
if (this.policies == null) {
|
||||
this.policies =
|
||||
policy.getAssociatedPolicies().stream()
|
||||
.map(ManagedPermissionBean::new)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
return this.policies;
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,56 @@
|
||||
/*
|
||||
* Copyright 2016 Red Hat, Inc. and/or its affiliates
|
||||
* and other contributors as indicated by the @author tags.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.keycloak.forms.account.freemarker.model;
|
||||
|
||||
/**
|
||||
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
|
||||
*/
|
||||
public class FeaturesBean {
|
||||
|
||||
private final boolean identityFederation;
|
||||
private final boolean log;
|
||||
private final boolean passwordUpdateSupported;
|
||||
private boolean authorization;
|
||||
|
||||
public FeaturesBean(
|
||||
boolean identityFederation,
|
||||
boolean log,
|
||||
boolean passwordUpdateSupported,
|
||||
boolean authorization) {
|
||||
this.identityFederation = identityFederation;
|
||||
this.log = log;
|
||||
this.passwordUpdateSupported = passwordUpdateSupported;
|
||||
this.authorization = authorization;
|
||||
}
|
||||
|
||||
public boolean isIdentityFederation() {
|
||||
return identityFederation;
|
||||
}
|
||||
|
||||
public boolean isLog() {
|
||||
return log;
|
||||
}
|
||||
|
||||
public boolean isPasswordUpdateSupported() {
|
||||
return passwordUpdateSupported;
|
||||
}
|
||||
|
||||
public boolean isAuthorization() {
|
||||
return authorization;
|
||||
}
|
||||
}
|
@ -0,0 +1,95 @@
|
||||
/*
|
||||
* Copyright 2016 Red Hat, Inc. and/or its affiliates
|
||||
* and other contributors as indicated by the @author tags.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.keycloak.forms.account.freemarker.model;
|
||||
|
||||
import java.util.Date;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.keycloak.events.Event;
|
||||
|
||||
/**
|
||||
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
|
||||
*/
|
||||
public class LogBean {
|
||||
|
||||
private List<EventBean> events;
|
||||
|
||||
public LogBean(List<Event> events) {
|
||||
this.events = new LinkedList<EventBean>();
|
||||
for (Event e : events) {
|
||||
this.events.add(new EventBean(e));
|
||||
}
|
||||
}
|
||||
|
||||
public List<EventBean> getEvents() {
|
||||
return events;
|
||||
}
|
||||
|
||||
public static class EventBean {
|
||||
|
||||
private Event event;
|
||||
|
||||
public EventBean(Event event) {
|
||||
this.event = event;
|
||||
}
|
||||
|
||||
public Date getDate() {
|
||||
return new Date(event.getTime());
|
||||
}
|
||||
|
||||
public String getEvent() {
|
||||
return event.getType().toString().toLowerCase().replace("_", " ");
|
||||
}
|
||||
|
||||
public String getClient() {
|
||||
return event.getClientId();
|
||||
}
|
||||
|
||||
public String getIpAddress() {
|
||||
return event.getIpAddress();
|
||||
}
|
||||
|
||||
public List<DetailBean> getDetails() {
|
||||
List<DetailBean> details = new LinkedList<DetailBean>();
|
||||
if (event.getDetails() != null) {
|
||||
for (Map.Entry<String, String> e : event.getDetails().entrySet()) {
|
||||
details.add(new DetailBean(e));
|
||||
}
|
||||
}
|
||||
return details;
|
||||
}
|
||||
}
|
||||
|
||||
public static class DetailBean {
|
||||
|
||||
private Map.Entry<String, String> entry;
|
||||
|
||||
public DetailBean(Map.Entry<String, String> entry) {
|
||||
this.entry = entry;
|
||||
}
|
||||
|
||||
public String getKey() {
|
||||
return entry.getKey();
|
||||
}
|
||||
|
||||
public String getValue() {
|
||||
return entry.getValue().replace("_", " ");
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,34 @@
|
||||
/*
|
||||
* Copyright 2016 Red Hat, Inc. and/or its affiliates
|
||||
* and other contributors as indicated by the @author tags.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.keycloak.forms.account.freemarker.model;
|
||||
|
||||
/**
|
||||
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
|
||||
*/
|
||||
public class PasswordBean {
|
||||
|
||||
private boolean passwordSet;
|
||||
|
||||
public PasswordBean(boolean passwordSet) {
|
||||
this.passwordSet = passwordSet;
|
||||
}
|
||||
|
||||
public boolean isPasswordSet() {
|
||||
return passwordSet;
|
||||
}
|
||||
}
|
@ -0,0 +1,75 @@
|
||||
/*
|
||||
* Copyright 2016 Red Hat, Inc. and/or its affiliates
|
||||
* and other contributors as indicated by the @author tags.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package org.keycloak.forms.account.freemarker.model;
|
||||
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import org.keycloak.models.RealmModel;
|
||||
|
||||
/**
|
||||
* @author <a href="mailto:gerbermichi@me.com">Michael Gerber</a>
|
||||
*/
|
||||
public class RealmBean {
|
||||
|
||||
private RealmModel realm;
|
||||
|
||||
public RealmBean(RealmModel realmModel) {
|
||||
realm = realmModel;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return realm.getName();
|
||||
}
|
||||
|
||||
public String getDisplayName() {
|
||||
String displayName = realm.getDisplayName();
|
||||
if (displayName != null && displayName.length() > 0) {
|
||||
return displayName;
|
||||
} else {
|
||||
return getName();
|
||||
}
|
||||
}
|
||||
|
||||
public String getDisplayNameHtml() {
|
||||
String displayNameHtml = realm.getDisplayNameHtml();
|
||||
if (displayNameHtml != null && displayNameHtml.length() > 0) {
|
||||
return displayNameHtml;
|
||||
} else {
|
||||
return getDisplayName();
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isInternationalizationEnabled() {
|
||||
return realm.isInternationalizationEnabled();
|
||||
}
|
||||
|
||||
public Set<String> getSupportedLocales() {
|
||||
return realm.getSupportedLocalesStream().collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
public boolean isEditUsernameAllowed() {
|
||||
return realm.isEditUsernameAllowed();
|
||||
}
|
||||
|
||||
public boolean isRegistrationEmailAsUsername() {
|
||||
return realm.isRegistrationEmailAsUsername();
|
||||
}
|
||||
|
||||
public boolean isUserManagedAccessAllowed() {
|
||||
return realm.isUserManagedAccessAllowed();
|
||||
}
|
||||
}
|
@ -0,0 +1,38 @@
|
||||
/*
|
||||
* Copyright 2016 Red Hat, Inc. and/or its affiliates
|
||||
* and other contributors as indicated by the @author tags.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.keycloak.forms.account.freemarker.model;
|
||||
|
||||
/**
|
||||
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
|
||||
*/
|
||||
public class ReferrerBean {
|
||||
|
||||
private String[] referrer;
|
||||
|
||||
public ReferrerBean(String[] referrer) {
|
||||
this.referrer = referrer;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return referrer[0];
|
||||
}
|
||||
|
||||
public String getUrl() {
|
||||
return referrer[1];
|
||||
}
|
||||
}
|
@ -0,0 +1,93 @@
|
||||
/*
|
||||
* Copyright 2016 Red Hat, Inc. and/or its affiliates
|
||||
* and other contributors as indicated by the @author tags.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.keycloak.forms.account.freemarker.model;
|
||||
|
||||
import java.util.Date;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import org.keycloak.common.util.Time;
|
||||
import org.keycloak.models.ClientModel;
|
||||
import org.keycloak.models.RealmModel;
|
||||
import org.keycloak.models.UserSessionModel;
|
||||
|
||||
/**
|
||||
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
|
||||
*/
|
||||
public class SessionsBean {
|
||||
|
||||
private List<UserSessionBean> events;
|
||||
private RealmModel realm;
|
||||
|
||||
public SessionsBean(RealmModel realm, List<UserSessionModel> sessions) {
|
||||
this.events = new LinkedList<>();
|
||||
for (UserSessionModel session : sessions) {
|
||||
this.events.add(new UserSessionBean(realm, session));
|
||||
}
|
||||
}
|
||||
|
||||
public List<UserSessionBean> getSessions() {
|
||||
return events;
|
||||
}
|
||||
|
||||
public static class UserSessionBean {
|
||||
|
||||
private UserSessionModel session;
|
||||
private RealmModel realm;
|
||||
|
||||
public UserSessionBean(RealmModel realm, UserSessionModel session) {
|
||||
this.realm = realm;
|
||||
this.session = session;
|
||||
}
|
||||
|
||||
public String getId() {
|
||||
return session.getId();
|
||||
}
|
||||
|
||||
public String getIpAddress() {
|
||||
return session.getIpAddress();
|
||||
}
|
||||
|
||||
public Date getStarted() {
|
||||
return Time.toDate(session.getStarted());
|
||||
}
|
||||
|
||||
public Date getLastAccess() {
|
||||
return Time.toDate(session.getLastSessionRefresh());
|
||||
}
|
||||
|
||||
public Date getExpires() {
|
||||
int maxLifespan =
|
||||
session.isRememberMe() && realm.getSsoSessionMaxLifespanRememberMe() > 0
|
||||
? realm.getSsoSessionMaxLifespanRememberMe()
|
||||
: realm.getSsoSessionMaxLifespan();
|
||||
int max = session.getStarted() + maxLifespan;
|
||||
return Time.toDate(max);
|
||||
}
|
||||
|
||||
public Set<String> getClients() {
|
||||
Set<String> clients = new HashSet<>();
|
||||
for (String clientUUID : session.getAuthenticatedClientSessions().keySet()) {
|
||||
ClientModel client = realm.getClientById(clientUUID);
|
||||
clients.add(client.getClientId());
|
||||
}
|
||||
return clients;
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,125 @@
|
||||
/*
|
||||
* Copyright 2016 Red Hat, Inc. and/or its affiliates
|
||||
* and other contributors as indicated by the @author tags.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.keycloak.forms.account.freemarker.model;
|
||||
|
||||
import static org.keycloak.utils.CredentialHelper.createUserStorageCredentialRepresentation;
|
||||
|
||||
import jakarta.ws.rs.core.UriBuilder;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
import org.keycloak.authentication.otp.OTPApplicationProvider;
|
||||
import org.keycloak.credential.CredentialModel;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.OTPPolicy;
|
||||
import org.keycloak.models.RealmModel;
|
||||
import org.keycloak.models.UserModel;
|
||||
import org.keycloak.models.credential.OTPCredentialModel;
|
||||
import org.keycloak.models.utils.HmacOTP;
|
||||
import org.keycloak.models.utils.RepresentationToModel;
|
||||
import org.keycloak.representations.idm.CredentialRepresentation;
|
||||
import org.keycloak.utils.TotpUtils;
|
||||
|
||||
/**
|
||||
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
|
||||
*/
|
||||
public class TotpBean {
|
||||
|
||||
private final RealmModel realm;
|
||||
private final String totpSecret;
|
||||
private final String totpSecretEncoded;
|
||||
private final String totpSecretQrCode;
|
||||
private final boolean enabled;
|
||||
private KeycloakSession session;
|
||||
private final UriBuilder uriBuilder;
|
||||
private final List<CredentialModel> otpCredentials;
|
||||
private final List<String> supportedApplications;
|
||||
|
||||
public TotpBean(
|
||||
KeycloakSession session, RealmModel realm, UserModel user, UriBuilder uriBuilder) {
|
||||
this.session = session;
|
||||
this.uriBuilder = uriBuilder;
|
||||
this.enabled = user.credentialManager().isConfiguredFor(OTPCredentialModel.TYPE);
|
||||
if (enabled) {
|
||||
List<CredentialModel> otpCredentials =
|
||||
user.credentialManager()
|
||||
.getStoredCredentialsByTypeStream(OTPCredentialModel.TYPE)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
if (otpCredentials.isEmpty()) {
|
||||
// Credential is configured on userStorage side. Create the "fake" credential similar like
|
||||
// we do for the new account console
|
||||
CredentialRepresentation credential =
|
||||
createUserStorageCredentialRepresentation(OTPCredentialModel.TYPE);
|
||||
this.otpCredentials = Collections.singletonList(RepresentationToModel.toModel(credential));
|
||||
} else {
|
||||
this.otpCredentials = otpCredentials;
|
||||
}
|
||||
} else {
|
||||
this.otpCredentials = Collections.EMPTY_LIST;
|
||||
}
|
||||
|
||||
this.realm = realm;
|
||||
this.totpSecret = HmacOTP.generateSecret(20);
|
||||
this.totpSecretEncoded = TotpUtils.encode(totpSecret);
|
||||
this.totpSecretQrCode = TotpUtils.qrCode(totpSecret, realm, user);
|
||||
|
||||
OTPPolicy otpPolicy = realm.getOTPPolicy();
|
||||
this.supportedApplications =
|
||||
session.getAllProviders(OTPApplicationProvider.class).stream()
|
||||
.filter(p -> p.supports(otpPolicy))
|
||||
.map(OTPApplicationProvider::getName)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
public boolean isEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public String getTotpSecret() {
|
||||
return totpSecret;
|
||||
}
|
||||
|
||||
public String getTotpSecretEncoded() {
|
||||
return totpSecretEncoded;
|
||||
}
|
||||
|
||||
public String getTotpSecretQrCode() {
|
||||
return totpSecretQrCode;
|
||||
}
|
||||
|
||||
public String getManualUrl() {
|
||||
return uriBuilder.replaceQueryParam("mode", "manual").build().toString();
|
||||
}
|
||||
|
||||
public String getQrUrl() {
|
||||
return uriBuilder.replaceQueryParam("mode", "qr").build().toString();
|
||||
}
|
||||
|
||||
public OTPPolicy getPolicy() {
|
||||
return realm.getOTPPolicy();
|
||||
}
|
||||
|
||||
public List<String> getSupportedApplications() {
|
||||
return supportedApplications;
|
||||
}
|
||||
|
||||
public List<CredentialModel> getOtpCredentials() {
|
||||
return otpCredentials;
|
||||
}
|
||||
}
|
@ -0,0 +1,121 @@
|
||||
/*
|
||||
* Copyright 2016 Red Hat, Inc. and/or its affiliates
|
||||
* and other contributors as indicated by the @author tags.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.keycloak.forms.account.freemarker.model;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import org.jboss.logging.Logger;
|
||||
import org.keycloak.models.RealmModel;
|
||||
import org.keycloak.services.AccountUrls;
|
||||
import org.keycloak.theme.Theme;
|
||||
|
||||
/**
|
||||
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
|
||||
*/
|
||||
public class UrlBean {
|
||||
|
||||
private static final Logger logger = Logger.getLogger(UrlBean.class);
|
||||
private String realm;
|
||||
private Theme theme;
|
||||
private URI baseURI;
|
||||
private URI baseQueryURI;
|
||||
private URI currentURI;
|
||||
private String idTokenHint;
|
||||
|
||||
public UrlBean(
|
||||
RealmModel realm,
|
||||
Theme theme,
|
||||
URI baseURI,
|
||||
URI baseQueryURI,
|
||||
URI currentURI,
|
||||
String idTokenHint) {
|
||||
this.realm = realm.getName();
|
||||
this.theme = theme;
|
||||
this.baseURI = baseURI;
|
||||
this.baseQueryURI = baseQueryURI;
|
||||
this.currentURI = currentURI;
|
||||
this.idTokenHint = idTokenHint;
|
||||
}
|
||||
|
||||
public String getApplicationsUrl() {
|
||||
return AccountUrls.accountApplicationsPage(baseQueryURI, realm).toString();
|
||||
}
|
||||
|
||||
public String getAccountUrl() {
|
||||
return AccountUrls.accountPage(baseQueryURI, realm).toString();
|
||||
}
|
||||
|
||||
public String getPasswordUrl() {
|
||||
return AccountUrls.accountPasswordPage(baseQueryURI, realm).toString();
|
||||
}
|
||||
|
||||
public String getSocialUrl() {
|
||||
return AccountUrls.accountFederatedIdentityPage(baseQueryURI, realm).toString();
|
||||
}
|
||||
|
||||
public String getTotpUrl() {
|
||||
return AccountUrls.accountTotpPage(baseQueryURI, realm).toString();
|
||||
}
|
||||
|
||||
public String getLogUrl() {
|
||||
return AccountUrls.accountLogPage(baseQueryURI, realm).toString();
|
||||
}
|
||||
|
||||
public String getSessionsUrl() {
|
||||
return AccountUrls.accountSessionsPage(baseQueryURI, realm).toString();
|
||||
}
|
||||
|
||||
public String getLogoutUrl() {
|
||||
return AccountUrls.accountLogout(baseQueryURI, currentURI, realm, idTokenHint).toString();
|
||||
}
|
||||
|
||||
public String getResourceUrl() {
|
||||
return AccountUrls.accountResourcesPage(baseQueryURI, realm).toString();
|
||||
}
|
||||
|
||||
public String getResourceDetailUrl(String id) {
|
||||
return AccountUrls.accountResourceDetailPage(id, baseQueryURI, realm).toString();
|
||||
}
|
||||
|
||||
public String getResourceGrant(String id) {
|
||||
return AccountUrls.accountResourceGrant(id, baseQueryURI, realm).toString();
|
||||
}
|
||||
|
||||
public String getResourceShare(String id) {
|
||||
return AccountUrls.accountResourceShare(id, baseQueryURI, realm).toString();
|
||||
}
|
||||
|
||||
public String getResourcesPath() {
|
||||
URI uri = AccountUrls.themeRoot(baseURI);
|
||||
return uri.getPath() + "/" + theme.getType().toString().toLowerCase() + "/" + theme.getName();
|
||||
}
|
||||
|
||||
public String getResourcesCommonPath() {
|
||||
URI uri = AccountUrls.themeRoot(baseURI);
|
||||
String commonPath = "";
|
||||
try {
|
||||
commonPath = theme.getProperties().getProperty("import");
|
||||
} catch (IOException ex) {
|
||||
logger.warn("Failed to load properties", ex);
|
||||
}
|
||||
if (commonPath == null || commonPath.isEmpty()) {
|
||||
commonPath = "/common/keycloak";
|
||||
}
|
||||
return uri.getPath() + "/" + commonPath;
|
||||
}
|
||||
}
|
@ -0,0 +1,115 @@
|
||||
package org.keycloak.services;
|
||||
|
||||
import jakarta.ws.rs.core.UriBuilder;
|
||||
import java.net.URI;
|
||||
import lombok.extern.jbosslog.JBossLog;
|
||||
import org.keycloak.OAuth2Constants;
|
||||
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
|
||||
import org.keycloak.protocol.oidc.OIDCLoginProtocolService;
|
||||
import org.keycloak.services.resources.LoginActionsService;
|
||||
import org.keycloak.services.resources.RealmsResource;
|
||||
import org.keycloak.services.resources.account.AccountFormService;
|
||||
|
||||
@JBossLog
|
||||
public class AccountUrls extends Urls {
|
||||
|
||||
private static UriBuilder realmLogout(URI baseUri) {
|
||||
return tokenBase(baseUri).path(OIDCLoginProtocolService.class, "logout");
|
||||
}
|
||||
|
||||
public static UriBuilder accountBase(URI baseUri) {
|
||||
return realmBase(baseUri).path(RealmsResource.class, "getAccountService");
|
||||
}
|
||||
|
||||
private static UriBuilder tokenBase(URI baseUri) {
|
||||
return realmBase(baseUri).path("{realm}/protocol/" + OIDCLoginProtocol.LOGIN_PROTOCOL);
|
||||
}
|
||||
|
||||
public static URI accountApplicationsPage(URI baseUri, String realmName) {
|
||||
return accountBase(baseUri).path(AccountFormService.class, "applicationsPage").build(realmName);
|
||||
}
|
||||
|
||||
public static URI accountPage(URI baseUri, String realmName) {
|
||||
return accountPageBuilder(baseUri).build(realmName);
|
||||
}
|
||||
|
||||
public static UriBuilder accountPageBuilder(URI baseUri) {
|
||||
return accountBase(baseUri).path(AccountFormService.class, "accountPage");
|
||||
}
|
||||
|
||||
public static URI accountPasswordPage(URI baseUri, String realmName) {
|
||||
return accountBase(baseUri).path(AccountFormService.class, "passwordPage").build(realmName);
|
||||
}
|
||||
|
||||
public static URI accountFederatedIdentityPage(URI baseUri, String realmName) {
|
||||
return accountBase(baseUri)
|
||||
.path(AccountFormService.class, "federatedIdentityPage")
|
||||
.build(realmName);
|
||||
}
|
||||
|
||||
public static URI accountFederatedIdentityUpdate(URI baseUri, String realmName) {
|
||||
return accountBase(baseUri)
|
||||
.path(AccountFormService.class, "processFederatedIdentityUpdate")
|
||||
.build(realmName);
|
||||
}
|
||||
|
||||
public static URI accountTotpPage(URI baseUri, String realmName) {
|
||||
return accountBase(baseUri).path(AccountFormService.class, "totpPage").build(realmName);
|
||||
}
|
||||
|
||||
public static URI accountLogPage(URI baseUri, String realmName) {
|
||||
return accountBase(baseUri).path(AccountFormService.class, "logPage").build(realmName);
|
||||
}
|
||||
|
||||
public static URI accountSessionsPage(URI baseUri, String realmName) {
|
||||
return accountBase(baseUri).path(AccountFormService.class, "sessionsPage").build(realmName);
|
||||
}
|
||||
|
||||
public static URI accountLogout(
|
||||
URI baseUri, URI redirectUri, String realmName, String idTokenHint) {
|
||||
return realmLogout(baseUri)
|
||||
.queryParam(OAuth2Constants.POST_LOGOUT_REDIRECT_URI, redirectUri)
|
||||
.queryParam(OAuth2Constants.ID_TOKEN_HINT, idTokenHint)
|
||||
.build(realmName);
|
||||
}
|
||||
|
||||
public static URI accountResourcesPage(URI baseUri, String realmName) {
|
||||
return accountBase(baseUri).path(AccountFormService.class, "resourcesPage").build(realmName);
|
||||
}
|
||||
|
||||
public static URI accountResourceDetailPage(String resourceId, URI baseUri, String realmName) {
|
||||
return accountBase(baseUri)
|
||||
.path(AccountFormService.class, "resourceDetailPage")
|
||||
.build(realmName, resourceId);
|
||||
}
|
||||
|
||||
public static URI accountResourceGrant(String resourceId, URI baseUri, String realmName) {
|
||||
return accountBase(baseUri)
|
||||
.path(AccountFormService.class, "grantPermission")
|
||||
.build(realmName, resourceId);
|
||||
}
|
||||
|
||||
public static URI accountResourceShare(String resourceId, URI baseUri, String realmName) {
|
||||
return accountBase(baseUri)
|
||||
.path(AccountFormService.class, "shareResource")
|
||||
.build(realmName, resourceId);
|
||||
}
|
||||
|
||||
public static URI loginActionUpdatePassword(URI baseUri, String realmName) {
|
||||
return loginActionsBase(baseUri)
|
||||
.path(LoginActionsService.class, "updatePassword")
|
||||
.build(realmName);
|
||||
}
|
||||
|
||||
public static URI loginActionUpdateTotp(URI baseUri, String realmName) {
|
||||
return loginActionsBase(baseUri).path(LoginActionsService.class, "updateTotp").build(realmName);
|
||||
}
|
||||
|
||||
public static URI loginActionEmailVerification(URI baseUri, String realmName) {
|
||||
return loginActionEmailVerificationBuilder(baseUri).build(realmName);
|
||||
}
|
||||
|
||||
public static String localeCookiePath(URI baseUri, String realmName) {
|
||||
return realmBase(baseUri).path(realmName).build().getRawPath();
|
||||
}
|
||||
}
|
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,64 @@
|
||||
package org.keycloak.services.resources.account;
|
||||
|
||||
import com.google.auto.service.AutoService;
|
||||
import com.google.common.collect.ImmutableMap;
|
||||
import com.google.common.collect.ImmutableSet;
|
||||
import java.util.Map;
|
||||
import lombok.extern.jbosslog.JBossLog;
|
||||
import org.keycloak.Config.Scope;
|
||||
import org.keycloak.events.EventBuilder;
|
||||
import org.keycloak.models.ClientModel;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.KeycloakSessionFactory;
|
||||
import org.keycloak.models.ProtocolMapperModel;
|
||||
import org.keycloak.models.RealmModel;
|
||||
import org.keycloak.provider.ProviderEvent;
|
||||
import org.keycloak.services.resource.AccountResourceProvider;
|
||||
import org.keycloak.services.resource.AccountResourceProviderFactory;
|
||||
import jakarta.ws.rs.NotFoundException;
|
||||
import org.keycloak.models.Constants;
|
||||
|
||||
@JBossLog
|
||||
@AutoService(AccountResourceProviderFactory.class)
|
||||
public class AccountFormServiceFactory implements AccountResourceProviderFactory {
|
||||
|
||||
public static final String ID = "account-v1";
|
||||
|
||||
@Override
|
||||
public String getId() {
|
||||
return ID;
|
||||
}
|
||||
|
||||
private ClientModel getAccountManagementClient(RealmModel realm) {
|
||||
ClientModel client = realm.getClientByClientId(Constants.ACCOUNT_MANAGEMENT_CLIENT_ID);
|
||||
if (client == null || !client.isEnabled()) {
|
||||
log.debug("account management not enabled");
|
||||
throw new NotFoundException("account management not enabled");
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
@Override
|
||||
public AccountResourceProvider create(KeycloakSession session) {
|
||||
log.info("create");
|
||||
RealmModel realm = session.getContext().getRealm();
|
||||
ClientModel client = getAccountManagementClient(realm);
|
||||
EventBuilder event = new EventBuilder(realm, session, session.getContext().getConnection());
|
||||
return new AccountFormService(session, client, event);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void init(Scope config) {
|
||||
log.info("init");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void postInit(KeycloakSessionFactory factory) {
|
||||
log.info("postInit");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
log.info("close");
|
||||
}
|
||||
}
|
@ -0,0 +1,249 @@
|
||||
import * as fs from "fs";
|
||||
import { join as pathJoin, dirname as pathDirname, basename as pathBasename } from "path";
|
||||
import { assert } from "tsafe/assert";
|
||||
import { Reflect } from "tsafe/Reflect";
|
||||
import type { BuildOptions } from "../BuildOptions";
|
||||
import type { ThemeType } from "../generateFtl";
|
||||
import { downloadBuiltinKeycloakTheme } from "../../download-builtin-keycloak-theme";
|
||||
import { transformCodebase } from "../../tools/transformCodebase";
|
||||
|
||||
export type BuildOptionsLike = {
|
||||
themeName: string;
|
||||
extraThemeNames: string[];
|
||||
groupId: string;
|
||||
artifactId: string;
|
||||
themeVersion: string;
|
||||
};
|
||||
|
||||
{
|
||||
const buildOptions = Reflect<BuildOptions>();
|
||||
|
||||
assert<typeof buildOptions extends BuildOptionsLike ? true : false>();
|
||||
}
|
||||
|
||||
export const accountV1Keycloak = "account-v1-keycloak";
|
||||
|
||||
export async function generateJavaStackFiles(params: {
|
||||
keycloakThemeBuildingDirPath: string;
|
||||
implementedThemeTypes: Record<ThemeType | "email", boolean>;
|
||||
buildOptions: BuildOptionsLike;
|
||||
}): Promise<{
|
||||
jarFilePath: string;
|
||||
}> {
|
||||
const {
|
||||
buildOptions: { groupId, themeName, extraThemeNames, themeVersion, artifactId },
|
||||
keycloakThemeBuildingDirPath,
|
||||
implementedThemeTypes
|
||||
} = params;
|
||||
|
||||
{
|
||||
const { pomFileCode } = (function generatePomFileCode(): {
|
||||
pomFileCode: string;
|
||||
} {
|
||||
const pomFileCode = [
|
||||
`<?xml version="1.0"?>`,
|
||||
`<project xmlns="http://maven.apache.org/POM/4.0.0"`,
|
||||
` xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"`,
|
||||
` xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">`,
|
||||
` <modelVersion>4.0.0</modelVersion>`,
|
||||
` <groupId>${groupId}</groupId>`,
|
||||
` <artifactId>${artifactId}</artifactId>`,
|
||||
` <version>${themeVersion}</version>`,
|
||||
` <name>${artifactId}</name>`,
|
||||
` <description />`,
|
||||
` <packaging>jar</packaging>`,
|
||||
` <properties>`,
|
||||
` <java.version>17</java.version>`,
|
||||
` <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>`,
|
||||
` <keycloak.version>999.0.0-SNAPSHOT</keycloak.version>`,
|
||||
` <guava.version>32.0.0-jre</guava.version>`,
|
||||
` <lombok.version>1.18.28</lombok.version>`,
|
||||
` <auto-service.version>1.1.1</auto-service.version>`,
|
||||
` </properties>`,
|
||||
` <build>`,
|
||||
` <plugins>`,
|
||||
` <plugin>`,
|
||||
` <artifactId>maven-compiler-plugin</artifactId>`,
|
||||
` <version>3.11.0</version>`,
|
||||
` <configuration>`,
|
||||
` <source>\${java.version}</source>`,
|
||||
` <target>\${java.version}</target>`,
|
||||
` <compilerArgument>-Xlint:unchecked</compilerArgument>`,
|
||||
` <compilerArgument>-Xlint:deprecation</compilerArgument>`,
|
||||
` <useIncrementalCompilation>false</useIncrementalCompilation>`,
|
||||
` <annotationProcessorPaths>`,
|
||||
` <path>`,
|
||||
` <groupId>com.google.auto.service</groupId>`,
|
||||
` <artifactId>auto-service</artifactId>`,
|
||||
` <version>\${auto-service.version}</version>`,
|
||||
` </path>`,
|
||||
` <path>`,
|
||||
` <groupId>org.projectlombok</groupId>`,
|
||||
` <artifactId>lombok</artifactId>`,
|
||||
` <version>\${lombok.version}</version>`,
|
||||
` </path>`,
|
||||
` </annotationProcessorPaths>`,
|
||||
` </configuration>`,
|
||||
` </plugin>`,
|
||||
` <plugin>`,
|
||||
` <groupId>org.apache.maven.plugins</groupId>`,
|
||||
` <artifactId>maven-jar-plugin</artifactId>`,
|
||||
` <version>3.2.0</version>`,
|
||||
` <configuration>`,
|
||||
` <archive>`,
|
||||
` <manifestEntries>`,
|
||||
` <Dependencies>`,
|
||||
` <![CDATA[org.keycloak.keycloak-common,org.keycloak.keycloak-core,org.keycloak.keycloak-server-spi,org.keycloak.keycloak-server-spi-private,org.keycloak.keycloak-services,com.google.guava]]>`,
|
||||
` </Dependencies>`,
|
||||
` </manifestEntries>`,
|
||||
` </archive>`,
|
||||
` </configuration>`,
|
||||
` </plugin>`,
|
||||
` <plugin>`,
|
||||
` <groupId>com.spotify.fmt</groupId>`,
|
||||
` <artifactId>fmt-maven-plugin</artifactId>`,
|
||||
` <version>2.20</version>`,
|
||||
` </plugin>`,
|
||||
` </plugins>`,
|
||||
` </build>`,
|
||||
` <dependencies>`,
|
||||
` <dependency>`,
|
||||
` <groupId>org.projectlombok</groupId>`,
|
||||
` <artifactId>lombok</artifactId>`,
|
||||
` <version>\${lombok.version}</version>`,
|
||||
` <scope>provided</scope>`,
|
||||
` </dependency>`,
|
||||
` <dependency>`,
|
||||
` <groupId>com.google.auto.service</groupId>`,
|
||||
` <artifactId>auto-service</artifactId>`,
|
||||
` <version>\${auto-service.version}</version>`,
|
||||
` <scope>provided</scope>`,
|
||||
` </dependency>`,
|
||||
` <dependency>`,
|
||||
` <groupId>org.keycloak</groupId>`,
|
||||
` <artifactId>keycloak-server-spi</artifactId>`,
|
||||
` <version>\${keycloak.version}</version>`,
|
||||
` <scope>provided</scope>`,
|
||||
` </dependency>`,
|
||||
` <dependency>`,
|
||||
` <groupId>org.keycloak</groupId>`,
|
||||
` <artifactId>keycloak-server-spi-private</artifactId>`,
|
||||
` <version>\${keycloak.version}</version>`,
|
||||
` <scope>provided</scope>`,
|
||||
` </dependency>`,
|
||||
` <dependency>`,
|
||||
` <groupId>org.keycloak</groupId>`,
|
||||
` <artifactId>keycloak-services</artifactId>`,
|
||||
` <version>\${keycloak.version}</version>`,
|
||||
` <scope>provided</scope>`,
|
||||
` </dependency>`,
|
||||
` <dependency>`,
|
||||
` <groupId>jakarta.ws.rs</groupId>`,
|
||||
` <artifactId>jakarta.ws.rs-api</artifactId>`,
|
||||
` <version>3.1.0</version>`,
|
||||
` <scope>provided</scope>`,
|
||||
` </dependency>`,
|
||||
` <dependency>`,
|
||||
` <groupId>com.google.guava</groupId>`,
|
||||
` <artifactId>guava</artifactId>`,
|
||||
` <version>\${guava.version}</version>`,
|
||||
` <scope>provided</scope>`,
|
||||
` </dependency>`,
|
||||
` </dependencies>`,
|
||||
`</project>`
|
||||
].join("\n");
|
||||
|
||||
return { pomFileCode };
|
||||
})();
|
||||
|
||||
fs.writeFileSync(pathJoin(keycloakThemeBuildingDirPath, "pom.xml"), Buffer.from(pomFileCode, "utf8"));
|
||||
}
|
||||
|
||||
const accountV1 = "account-v1";
|
||||
|
||||
{
|
||||
const builtinKeycloakThemeTmpDirPath = pathJoin(keycloakThemeBuildingDirPath, "..", "tmp_yxdE2_builtin_keycloak_theme");
|
||||
|
||||
await downloadBuiltinKeycloakTheme({
|
||||
"destDirPath": builtinKeycloakThemeTmpDirPath,
|
||||
"isSilent": true,
|
||||
"keycloakVersion": "21.1.2"
|
||||
});
|
||||
|
||||
transformCodebase({
|
||||
"srcDirPath": pathJoin(builtinKeycloakThemeTmpDirPath, "base", "account"),
|
||||
"destDirPath": pathJoin(keycloakThemeBuildingDirPath, "src", "main", "resources", "theme", accountV1, "account")
|
||||
});
|
||||
|
||||
transformCodebase({
|
||||
"srcDirPath": pathJoin(builtinKeycloakThemeTmpDirPath, "keycloak", "common"),
|
||||
"destDirPath": pathJoin(keycloakThemeBuildingDirPath, "src", "main", "resources", "theme", accountV1Keycloak, "common")
|
||||
});
|
||||
|
||||
transformCodebase({
|
||||
"srcDirPath": pathJoin(builtinKeycloakThemeTmpDirPath, "keycloak", "account"),
|
||||
"destDirPath": pathJoin(keycloakThemeBuildingDirPath, "src", "main", "resources", "theme", accountV1Keycloak, "account"),
|
||||
"transformSourceCode": ({ sourceCode, filePath }) => {
|
||||
if (pathBasename(filePath) !== "theme.properties") {
|
||||
sourceCode = Buffer.from(sourceCode.toString("utf8").replace("parent=base", `parent=${accountV1}`), "utf8");
|
||||
sourceCode = Buffer.from(
|
||||
sourceCode.toString("utf8").replace("import=common/keycloak", `import=common/${accountV1Keycloak}`),
|
||||
"utf8"
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
"modifiedSourceCode": sourceCode
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
fs.rmdirSync(builtinKeycloakThemeTmpDirPath, { "recursive": true });
|
||||
}
|
||||
|
||||
transformCodebase({
|
||||
"srcDirPath": pathJoin(__dirname, "account-v1-java"),
|
||||
"destDirPath": pathJoin(keycloakThemeBuildingDirPath, "src", "main", "java", "org", "keycloak")
|
||||
});
|
||||
|
||||
{
|
||||
const themeManifestFilePath = pathJoin(keycloakThemeBuildingDirPath, "src", "main", "resources", "META-INF", "keycloak-themes.json");
|
||||
|
||||
try {
|
||||
fs.mkdirSync(pathDirname(themeManifestFilePath));
|
||||
} catch {}
|
||||
|
||||
fs.writeFileSync(
|
||||
themeManifestFilePath,
|
||||
Buffer.from(
|
||||
JSON.stringify(
|
||||
{
|
||||
"themes": [
|
||||
{
|
||||
"name": "account-v1",
|
||||
"types": ["account"]
|
||||
},
|
||||
{
|
||||
"name": "account-v1-keycloak",
|
||||
"types": ["account"]
|
||||
},
|
||||
...[themeName, ...extraThemeNames].map(themeName => ({
|
||||
"name": themeName,
|
||||
"types": Object.entries(implementedThemeTypes)
|
||||
.filter(([, isImplemented]) => isImplemented)
|
||||
.map(([themeType]) => themeType)
|
||||
}))
|
||||
]
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
"utf8"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
"jarFilePath": pathJoin(keycloakThemeBuildingDirPath, "target", `${artifactId}-${themeVersion}.jar`)
|
||||
};
|
||||
}
|
1
src/bin/keycloakify/generateJavaStackFiles/index.ts
Normal file
1
src/bin/keycloakify/generateJavaStackFiles/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./generateJavaStackFiles";
|
@ -6,6 +6,7 @@ import type { BuildOptions } from "./BuildOptions";
|
||||
|
||||
export type BuildOptionsLike = {
|
||||
themeName: string;
|
||||
extraThemeNames: string[];
|
||||
};
|
||||
|
||||
{
|
||||
@ -27,14 +28,11 @@ export function generateStartKeycloakTestingContainer(params: {
|
||||
const {
|
||||
keycloakThemeBuildingDirPath,
|
||||
keycloakVersion,
|
||||
buildOptions: { themeName }
|
||||
buildOptions: { themeName, extraThemeNames }
|
||||
} = params;
|
||||
|
||||
const keycloakThemePath = pathJoin(keycloakThemeBuildingDirPath, "src", "main", "resources", "theme", themeName).replace(/\\/g, "/");
|
||||
|
||||
fs.writeFileSync(
|
||||
pathJoin(keycloakThemeBuildingDirPath, generateStartKeycloakTestingContainer.basename),
|
||||
|
||||
Buffer.from(
|
||||
[
|
||||
"#!/usr/bin/env bash",
|
||||
@ -48,10 +46,15 @@ export function generateStartKeycloakTestingContainer(params: {
|
||||
` --name ${containerName} \\`,
|
||||
" -e KEYCLOAK_ADMIN=admin \\",
|
||||
" -e KEYCLOAK_ADMIN_PASSWORD=admin \\",
|
||||
" -e JAVA_OPTS=-Dkeycloak.profile=preview \\",
|
||||
` -v "${keycloakThemePath}":"/opt/keycloak/themes/${themeName}":rw \\`,
|
||||
...[themeName, ...extraThemeNames].map(
|
||||
themeName =>
|
||||
` -v "${pathJoin(keycloakThemeBuildingDirPath, "src", "main", "resources", "theme", themeName).replace(
|
||||
/\\/g,
|
||||
"/"
|
||||
)}":"/opt/keycloak/themes/${themeName}":rw \\`
|
||||
),
|
||||
` -it quay.io/keycloak/keycloak:${keycloakVersion} \\`,
|
||||
` start-dev`,
|
||||
` start-dev --features=declarative-user-profile`,
|
||||
""
|
||||
].join("\n"),
|
||||
"utf8"
|
||||
|
179
src/bin/keycloakify/generateTheme/generateMessageProperties.ts
Normal file
179
src/bin/keycloakify/generateTheme/generateMessageProperties.ts
Normal file
@ -0,0 +1,179 @@
|
||||
import type { ThemeType } from "../generateFtl";
|
||||
import { crawl } from "../../tools/crawl";
|
||||
import { join as pathJoin } from "path";
|
||||
import { readFileSync } from "fs";
|
||||
import { symToStr } from "tsafe/symToStr";
|
||||
import { removeDuplicates } from "evt/tools/reducers/removeDuplicates";
|
||||
import * as recast from "recast";
|
||||
import * as babelParser from "@babel/parser";
|
||||
import babelGenerate from "@babel/generator";
|
||||
import * as babelTypes from "@babel/types";
|
||||
|
||||
export function generateMessageProperties(params: {
|
||||
themeSrcDirPath: string;
|
||||
themeType: ThemeType;
|
||||
}): { languageTag: string; propertiesFileSource: string }[] {
|
||||
const { themeSrcDirPath, themeType } = params;
|
||||
|
||||
let files = crawl({
|
||||
"dirPath": pathJoin(themeSrcDirPath, themeType),
|
||||
"returnedPathsType": "absolute"
|
||||
});
|
||||
|
||||
files = files.filter(file => {
|
||||
const regex = /\.(js|ts|tsx)$/;
|
||||
return regex.test(file);
|
||||
});
|
||||
|
||||
files = files.sort((a, b) => {
|
||||
const regex = /\.i18n\.(ts|js|tsx)$/;
|
||||
const aIsI18nFile = regex.test(a);
|
||||
const bIsI18nFile = regex.test(b);
|
||||
return aIsI18nFile === bIsI18nFile ? 0 : aIsI18nFile ? -1 : 1;
|
||||
});
|
||||
|
||||
files = files.sort((a, b) => a.length - b.length);
|
||||
|
||||
files = files.filter(file => readFileSync(file).toString("utf8").includes("createUseI18n"));
|
||||
|
||||
if (files.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const extraMessages = files
|
||||
.map(file => {
|
||||
const root = recast.parse(readFileSync(file).toString("utf8"), {
|
||||
"parser": {
|
||||
"parse": (code: string) => babelParser.parse(code, { "sourceType": "module", "plugins": ["typescript"] }),
|
||||
"generator": babelGenerate,
|
||||
"types": babelTypes
|
||||
}
|
||||
});
|
||||
|
||||
const codes: string[] = [];
|
||||
|
||||
recast.visit(root, {
|
||||
"visitCallExpression": function (path) {
|
||||
if (path.node.callee.type === "Identifier" && path.node.callee.name === "createUseI18n") {
|
||||
codes.push(babelGenerate(path.node.arguments[0] as any).code);
|
||||
}
|
||||
this.traverse(path);
|
||||
}
|
||||
});
|
||||
|
||||
return codes;
|
||||
})
|
||||
.flat()
|
||||
.map(code => {
|
||||
let extraMessages: { [languageTag: string]: Record<string, string> } = {};
|
||||
|
||||
try {
|
||||
eval(`${symToStr({ extraMessages })} = ${code}`);
|
||||
} catch {
|
||||
console.warn(
|
||||
[
|
||||
"WARNING: Make sure that the first argument of createUseI18n can be evaluated in a javascript",
|
||||
"runtime where only the node globals are available.",
|
||||
"This is important because we need to put your i18n messages in messages_*.properties files",
|
||||
"or they won't be available server side.",
|
||||
"\n",
|
||||
"The following code could not be evaluated:",
|
||||
"\n",
|
||||
code
|
||||
].join(" ")
|
||||
);
|
||||
}
|
||||
|
||||
return extraMessages;
|
||||
});
|
||||
|
||||
const languageTags = extraMessages
|
||||
.map(extraMessage => Object.keys(extraMessage))
|
||||
.flat()
|
||||
.reduce(...removeDuplicates<string>());
|
||||
|
||||
const keyValueMapByLanguageTag: Record<string, Record<string, string>> = {};
|
||||
|
||||
for (const languageTag of languageTags) {
|
||||
const keyValueMap: Record<string, string> = {};
|
||||
|
||||
for (const extraMessage of extraMessages) {
|
||||
const keyValueMap_i = extraMessage[languageTag];
|
||||
|
||||
if (keyValueMap_i === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(keyValueMap_i)) {
|
||||
if (keyValueMap[key] !== undefined) {
|
||||
console.warn(
|
||||
[
|
||||
"WARNING: The following key is defined multiple times:",
|
||||
"\n",
|
||||
key,
|
||||
"\n",
|
||||
"The following value will be ignored:",
|
||||
"\n",
|
||||
value,
|
||||
"\n",
|
||||
"The following value was already defined:",
|
||||
"\n",
|
||||
keyValueMap[key]
|
||||
].join(" ")
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
keyValueMap[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
keyValueMapByLanguageTag[languageTag] = keyValueMap;
|
||||
}
|
||||
|
||||
const out: { languageTag: string; propertiesFileSource: string }[] = [];
|
||||
|
||||
for (const [languageTag, keyValueMap] of Object.entries(keyValueMapByLanguageTag)) {
|
||||
const propertiesFileSource = Object.entries(keyValueMap)
|
||||
.map(([key, value]) => `${key}=${escapeString(value)}`)
|
||||
.join("\n");
|
||||
|
||||
out.push({
|
||||
languageTag,
|
||||
"propertiesFileSource": ["# This file was generated by keycloakify", "", "parent=base", "", propertiesFileSource, ""].join("\n")
|
||||
});
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
// Convert a JavaScript string to UTF-16 encoding
|
||||
function toUTF16(codePoint: number): string {
|
||||
if (codePoint <= 0xffff) {
|
||||
// BMP character
|
||||
return "\\u" + codePoint.toString(16).padStart(4, "0");
|
||||
} else {
|
||||
// Non-BMP character
|
||||
codePoint -= 0x10000;
|
||||
let highSurrogate = (codePoint >> 10) + 0xd800;
|
||||
let lowSurrogate = (codePoint % 0x400) + 0xdc00;
|
||||
return "\\u" + highSurrogate.toString(16).padStart(4, "0") + "\\u" + lowSurrogate.toString(16).padStart(4, "0");
|
||||
}
|
||||
}
|
||||
|
||||
// Escapes special characters and converts unicode to UTF-16 encoding
|
||||
function escapeString(str: string): string {
|
||||
let escapedStr = "";
|
||||
for (const char of [...str]) {
|
||||
const codePoint = char.codePointAt(0);
|
||||
if (!codePoint) continue;
|
||||
if (char === "'") {
|
||||
escapedStr += "''"; // double single quotes
|
||||
} else if (codePoint > 0x7f) {
|
||||
escapedStr += toUTF16(codePoint); // non-ascii characters
|
||||
} else {
|
||||
escapedStr += char;
|
||||
}
|
||||
}
|
||||
return escapedStr;
|
||||
}
|
@ -9,17 +9,18 @@ import { isInside } from "../../tools/isInside";
|
||||
import type { BuildOptions } from "../BuildOptions";
|
||||
import { assert } from "tsafe/assert";
|
||||
import { downloadKeycloakStaticResources } from "./downloadKeycloakStaticResources";
|
||||
import { readFieldNameUsage } from "./readFieldNameUsage";
|
||||
import { readExtraPagesNames } from "./readExtraPageNames";
|
||||
import { generateMessageProperties } from "./generateMessageProperties";
|
||||
import { accountV1Keycloak } from "../generateJavaStackFiles/generateJavaStackFiles";
|
||||
|
||||
export type BuildOptionsLike = BuildOptionsLike.Standalone | BuildOptionsLike.ExternalAssets;
|
||||
|
||||
export namespace BuildOptionsLike {
|
||||
export type Common = {
|
||||
themeName: string;
|
||||
extraLoginPages?: string[];
|
||||
extraAccountPages?: string[];
|
||||
extraThemeProperties?: string[];
|
||||
extraThemeProperties: string[] | undefined;
|
||||
isSilent: boolean;
|
||||
customUserAttributes: string[];
|
||||
themeVersion: string;
|
||||
keycloakVersionDefaultAssets: string;
|
||||
};
|
||||
@ -53,11 +54,12 @@ assert<BuildOptions extends BuildOptionsLike ? true : false>();
|
||||
export async function generateTheme(params: {
|
||||
reactAppBuildDirPath: string;
|
||||
keycloakThemeBuildingDirPath: string;
|
||||
emailThemeSrcDirPath: string | undefined;
|
||||
themeSrcDirPath: string;
|
||||
keycloakifySrcDirPath: string;
|
||||
buildOptions: BuildOptionsLike;
|
||||
keycloakifyVersion: string;
|
||||
}): Promise<{ doBundlesEmailTemplate: boolean }> {
|
||||
const { reactAppBuildDirPath, keycloakThemeBuildingDirPath, emailThemeSrcDirPath, buildOptions, keycloakifyVersion } = params;
|
||||
}): Promise<void> {
|
||||
const { reactAppBuildDirPath, keycloakThemeBuildingDirPath, themeSrcDirPath, keycloakifySrcDirPath, buildOptions, keycloakifyVersion } = params;
|
||||
|
||||
const getThemeDirPath = (themeType: ThemeType | "email") =>
|
||||
pathJoin(keycloakThemeBuildingDirPath, "src", "main", "resources", "theme", buildOptions.themeName, themeType);
|
||||
@ -67,6 +69,10 @@ export async function generateTheme(params: {
|
||||
let generateFtlFilesCode_glob: ReturnType<typeof generateFtlFilesCodeFactory>["generateFtlFilesCode"] | undefined = undefined;
|
||||
|
||||
for (const themeType of themeTypes) {
|
||||
if (!fs.existsSync(pathJoin(themeSrcDirPath, themeType))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const themeDirPath = getThemeDirPath(themeType);
|
||||
|
||||
copy_app_resources_to_theme_path: {
|
||||
@ -132,20 +138,21 @@ export async function generateTheme(params: {
|
||||
});
|
||||
}
|
||||
|
||||
const generateFtlFilesCode = (() => {
|
||||
if (generateFtlFilesCode_glob !== undefined) {
|
||||
return generateFtlFilesCode_glob;
|
||||
}
|
||||
|
||||
const { generateFtlFilesCode } = generateFtlFilesCodeFactory({
|
||||
"indexHtmlCode": fs.readFileSync(pathJoin(reactAppBuildDirPath, "index.html")).toString("utf8"),
|
||||
"cssGlobalsToDefine": allCssGlobalsToDefine,
|
||||
buildOptions,
|
||||
keycloakifyVersion
|
||||
});
|
||||
|
||||
return generateFtlFilesCode;
|
||||
})();
|
||||
const generateFtlFilesCode =
|
||||
generateFtlFilesCode_glob !== undefined
|
||||
? generateFtlFilesCode_glob
|
||||
: generateFtlFilesCodeFactory({
|
||||
"indexHtmlCode": fs.readFileSync(pathJoin(reactAppBuildDirPath, "index.html")).toString("utf8"),
|
||||
"cssGlobalsToDefine": allCssGlobalsToDefine,
|
||||
buildOptions,
|
||||
keycloakifyVersion,
|
||||
themeType,
|
||||
"fieldNames": readFieldNameUsage({
|
||||
keycloakifySrcDirPath,
|
||||
themeSrcDirPath,
|
||||
themeType
|
||||
})
|
||||
}).generateFtlFilesCode;
|
||||
|
||||
[
|
||||
...(() => {
|
||||
@ -156,14 +163,10 @@ export async function generateTheme(params: {
|
||||
return accountThemePageIds;
|
||||
}
|
||||
})(),
|
||||
...((() => {
|
||||
switch (themeType) {
|
||||
case "login":
|
||||
return buildOptions.extraLoginPages;
|
||||
case "account":
|
||||
return buildOptions.extraAccountPages;
|
||||
}
|
||||
})() ?? [])
|
||||
...readExtraPagesNames({
|
||||
themeType,
|
||||
themeSrcDirPath
|
||||
})
|
||||
].forEach(pageId => {
|
||||
const { ftlCode } = generateFtlFilesCode({ pageId });
|
||||
|
||||
@ -172,6 +175,19 @@ export async function generateTheme(params: {
|
||||
fs.writeFileSync(pathJoin(themeDirPath, pageId), Buffer.from(ftlCode, "utf8"));
|
||||
});
|
||||
|
||||
generateMessageProperties({
|
||||
themeSrcDirPath,
|
||||
themeType
|
||||
}).forEach(({ languageTag, propertiesFileSource }) => {
|
||||
const messagesDirPath = pathJoin(themeDirPath, "messages");
|
||||
|
||||
fs.mkdirSync(pathJoin(themeDirPath, "messages"), { "recursive": true });
|
||||
|
||||
const propertiesFilePath = pathJoin(messagesDirPath, `messages_${languageTag}.properties`);
|
||||
|
||||
fs.writeFileSync(propertiesFilePath, Buffer.from(propertiesFileSource, "utf8"));
|
||||
});
|
||||
|
||||
//TODO: Remove this block we left it for now only for backward compatibility
|
||||
// we now have a separate script for this
|
||||
copy_keycloak_resources_to_public: {
|
||||
@ -215,25 +231,33 @@ export async function generateTheme(params: {
|
||||
|
||||
fs.writeFileSync(
|
||||
pathJoin(themeDirPath, "theme.properties"),
|
||||
Buffer.from(["parent=keycloak", ...(buildOptions.extraThemeProperties ?? [])].join("\n\n"), "utf8")
|
||||
Buffer.from(
|
||||
[
|
||||
`parent=${(() => {
|
||||
switch (themeType) {
|
||||
case "login":
|
||||
return "keycloak";
|
||||
case "account":
|
||||
return accountV1Keycloak;
|
||||
}
|
||||
})()}`,
|
||||
...(buildOptions.extraThemeProperties ?? [])
|
||||
].join("\n\n"),
|
||||
"utf8"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
let doBundlesEmailTemplate: boolean;
|
||||
|
||||
email: {
|
||||
if (emailThemeSrcDirPath === undefined) {
|
||||
doBundlesEmailTemplate = false;
|
||||
const emailThemeSrcDirPath = pathJoin(themeSrcDirPath, "email");
|
||||
|
||||
if (!fs.existsSync(emailThemeSrcDirPath)) {
|
||||
break email;
|
||||
}
|
||||
|
||||
doBundlesEmailTemplate = true;
|
||||
|
||||
transformCodebase({
|
||||
"srcDirPath": emailThemeSrcDirPath,
|
||||
"destDirPath": getThemeDirPath("email")
|
||||
});
|
||||
}
|
||||
|
||||
return { doBundlesEmailTemplate };
|
||||
}
|
||||
|
38
src/bin/keycloakify/generateTheme/readExtraPageNames.ts
Normal file
38
src/bin/keycloakify/generateTheme/readExtraPageNames.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import { crawl } from "../../tools/crawl";
|
||||
import { type ThemeType, accountThemePageIds, loginThemePageIds } from "../generateFtl";
|
||||
import { id } from "tsafe/id";
|
||||
import { removeDuplicates } from "evt/tools/reducers/removeDuplicates";
|
||||
import * as fs from "fs";
|
||||
import { join as pathJoin } from "path";
|
||||
|
||||
export function readExtraPagesNames(params: { themeSrcDirPath: string; themeType: ThemeType }): string[] {
|
||||
const { themeSrcDirPath, themeType } = params;
|
||||
|
||||
const filePaths = crawl({
|
||||
"dirPath": pathJoin(themeSrcDirPath, themeType),
|
||||
"returnedPathsType": "absolute"
|
||||
}).filter(filePath => /\.(ts|tsx|js|jsx)$/.test(filePath));
|
||||
|
||||
const candidateFilePaths = filePaths.filter(filePath => /kcContext\.[^.]+$/.test(filePath));
|
||||
|
||||
if (candidateFilePaths.length === 0) {
|
||||
candidateFilePaths.push(...filePaths);
|
||||
}
|
||||
|
||||
const extraPages: string[] = [];
|
||||
|
||||
for (const candidateFilPath of candidateFilePaths) {
|
||||
const rawSourceFile = fs.readFileSync(candidateFilPath).toString("utf8");
|
||||
|
||||
extraPages.push(...Array.from(rawSourceFile.matchAll(/["']?pageId["']?\s*:\s*["']([^.]+.ftl)["']/g), m => m[1]));
|
||||
}
|
||||
|
||||
return extraPages.reduce(...removeDuplicates<string>()).filter(pageId => {
|
||||
switch (themeType) {
|
||||
case "account":
|
||||
return !id<readonly string[]>(accountThemePageIds).includes(pageId);
|
||||
case "login":
|
||||
return !id<readonly string[]>(loginThemePageIds).includes(pageId);
|
||||
}
|
||||
});
|
||||
}
|
35
src/bin/keycloakify/generateTheme/readFieldNameUsage.ts
Normal file
35
src/bin/keycloakify/generateTheme/readFieldNameUsage.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { crawl } from "../../tools/crawl";
|
||||
import { removeDuplicates } from "evt/tools/reducers/removeDuplicates";
|
||||
import { join as pathJoin } from "path";
|
||||
import * as fs from "fs";
|
||||
import type { ThemeType } from "../generateFtl";
|
||||
import { exclude } from "tsafe/exclude";
|
||||
|
||||
/** Assumes the theme type exists */
|
||||
export function readFieldNameUsage(params: { keycloakifySrcDirPath: string; themeSrcDirPath: string; themeType: ThemeType }): string[] {
|
||||
const { keycloakifySrcDirPath, themeSrcDirPath, themeType } = params;
|
||||
|
||||
const fieldNames: string[] = [];
|
||||
|
||||
for (const srcDirPath of ([pathJoin(keycloakifySrcDirPath, themeType), pathJoin(themeSrcDirPath, themeType)] as const).filter(
|
||||
exclude(undefined)
|
||||
)) {
|
||||
const filePaths = crawl({ "dirPath": srcDirPath, "returnedPathsType": "absolute" }).filter(filePath => /\.(ts|tsx|js|jsx)$/.test(filePath));
|
||||
|
||||
for (const filePath of filePaths) {
|
||||
const rawSourceFile = fs.readFileSync(filePath).toString("utf8");
|
||||
|
||||
if (!rawSourceFile.includes("messagesPerField")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
fieldNames.push(
|
||||
...Array.from(rawSourceFile.matchAll(/(?:(?:printIfExists)|(?:existsError)|(?:get)|(?:exists))\(\s*["']([^"']+)["']/g), m => m[1])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const out = fieldNames.reduce(...removeDuplicates<string>());
|
||||
|
||||
return out;
|
||||
}
|
@ -9,8 +9,9 @@ import { getLogger } from "../tools/logger";
|
||||
import jar from "../tools/jar";
|
||||
import { assert } from "tsafe/assert";
|
||||
import { Equals } from "tsafe";
|
||||
import { getEmailThemeSrcDirPath } from "../getSrcDirPath";
|
||||
import { getThemeSrcDirPath } from "../getSrcDirPath";
|
||||
import { getProjectRoot } from "../tools/getProjectRoot";
|
||||
import { objectKeys } from "tsafe/objectKeys";
|
||||
|
||||
export async function main() {
|
||||
const projectDirPath = process.cwd();
|
||||
@ -23,31 +24,48 @@ export async function main() {
|
||||
const logger = getLogger({ "isSilent": buildOptions.isSilent });
|
||||
logger.log("🔏 Building the keycloak theme...⌚");
|
||||
|
||||
const { doBundlesEmailTemplate } = await generateTheme({
|
||||
keycloakThemeBuildingDirPath: buildOptions.keycloakifyBuildDirPath,
|
||||
"emailThemeSrcDirPath": (() => {
|
||||
const { emailThemeSrcDirPath } = getEmailThemeSrcDirPath({ projectDirPath });
|
||||
const keycloakifyDirPath = getProjectRoot();
|
||||
|
||||
if (emailThemeSrcDirPath === undefined || !fs.existsSync(emailThemeSrcDirPath)) {
|
||||
return;
|
||||
const { themeSrcDirPath } = getThemeSrcDirPath({ projectDirPath });
|
||||
|
||||
for (const themeName of [buildOptions.themeName, ...buildOptions.extraThemeNames]) {
|
||||
await generateTheme({
|
||||
"keycloakThemeBuildingDirPath": buildOptions.keycloakifyBuildDirPath,
|
||||
themeSrcDirPath,
|
||||
"keycloakifySrcDirPath": pathJoin(keycloakifyDirPath, "src"),
|
||||
"reactAppBuildDirPath": buildOptions.reactAppBuildDirPath,
|
||||
"buildOptions": {
|
||||
...buildOptions,
|
||||
"themeName": themeName
|
||||
},
|
||||
"keycloakifyVersion": (() => {
|
||||
const version = JSON.parse(fs.readFileSync(pathJoin(keycloakifyDirPath, "package.json")).toString("utf8"))["version"];
|
||||
|
||||
assert(typeof version === "string");
|
||||
|
||||
return version;
|
||||
})()
|
||||
});
|
||||
}
|
||||
|
||||
const { jarFilePath } = await generateJavaStackFiles({
|
||||
"keycloakThemeBuildingDirPath": buildOptions.keycloakifyBuildDirPath,
|
||||
"implementedThemeTypes": (() => {
|
||||
const implementedThemeTypes = {
|
||||
"login": false,
|
||||
"account": false,
|
||||
"email": false
|
||||
};
|
||||
|
||||
for (const themeType of objectKeys(implementedThemeTypes)) {
|
||||
if (!fs.existsSync(pathJoin(themeSrcDirPath, themeType))) {
|
||||
continue;
|
||||
}
|
||||
implementedThemeTypes[themeType] = true;
|
||||
}
|
||||
|
||||
return emailThemeSrcDirPath;
|
||||
return implementedThemeTypes;
|
||||
})(),
|
||||
"reactAppBuildDirPath": buildOptions.reactAppBuildDirPath,
|
||||
buildOptions,
|
||||
"keycloakifyVersion": (() => {
|
||||
const version = JSON.parse(fs.readFileSync(pathJoin(getProjectRoot(), "package.json")).toString("utf8"))["version"];
|
||||
|
||||
assert(typeof version === "string");
|
||||
|
||||
return version;
|
||||
})()
|
||||
});
|
||||
|
||||
const { jarFilePath } = generateJavaStackFiles({
|
||||
keycloakThemeBuildingDirPath: buildOptions.keycloakifyBuildDirPath,
|
||||
doBundlesEmailTemplate,
|
||||
buildOptions
|
||||
});
|
||||
|
||||
@ -58,7 +76,7 @@ export async function main() {
|
||||
case "keycloakify":
|
||||
logger.log("🫶 Let keycloakify do its thang");
|
||||
await jar({
|
||||
"rootPath": pathJoin(buildOptions.keycloakifyBuildDirPath, "src", "main", "resources"),
|
||||
"rootPath": buildOptions.keycloakifyBuildDirPath,
|
||||
"version": buildOptions.themeVersion,
|
||||
"groupId": buildOptions.groupId,
|
||||
"artifactId": buildOptions.artifactId,
|
||||
@ -74,7 +92,7 @@ export async function main() {
|
||||
}
|
||||
|
||||
// We want, however, to test in a container running the latest Keycloak version
|
||||
const containerKeycloakVersion = "20.0.1";
|
||||
const containerKeycloakVersion = "21.1.2";
|
||||
|
||||
generateStartKeycloakTestingContainer({
|
||||
keycloakThemeBuildingDirPath: buildOptions.keycloakifyBuildDirPath,
|
||||
@ -128,16 +146,18 @@ export async function main() {
|
||||
``,
|
||||
`Once your container is up and running: `,
|
||||
"- Log into the admin console 👉 http://localhost:8080/admin username: admin, password: admin 👈",
|
||||
`- Create a realm: myrealm`,
|
||||
`- Enable registration: Realm settings -> Login tab -> User registration: on`,
|
||||
`- Enable the Account theme: Realm settings -> Themes tab -> Account theme, select ${buildOptions.themeName} `,
|
||||
`- Create a client id myclient`,
|
||||
` Root URL: https://www.keycloak.org/app/`,
|
||||
` Valid redirect URIs: https://www.keycloak.org/app* http://localhost* (localhost is optional)`,
|
||||
` Valid post logout redirect URIs: https://www.keycloak.org/app* http://localhost*`,
|
||||
` Web origins: *`,
|
||||
` Login Theme: ${buildOptions.themeName}`,
|
||||
` Save (button at the bottom of the page)`,
|
||||
`- Create a realm: Master -> AddRealm -> Name: myrealm`,
|
||||
`- Enable registration: Realm settings -> Login tab -> User registration: on`,
|
||||
`- Enable the Account theme (optional): Realm settings -> Themes tab -> Account theme: ${buildOptions.themeName}`,
|
||||
` Clients -> account -> Login theme: ${buildOptions.themeName}`,
|
||||
`- Enable the email theme (optional): Realm settings -> Themes tab -> Email theme: ${buildOptions.themeName} (option will appear only if you have ran npx initialize-email-theme)`,
|
||||
`- Create a client Clients -> Create -> Client ID: myclient`,
|
||||
` Root URL: https://www.keycloak.org/app/`,
|
||||
` Valid redirect URIs: https://www.keycloak.org/app* http://localhost* (localhost is optional)`,
|
||||
` Valid post logout redirect URIs: https://www.keycloak.org/app* http://localhost*`,
|
||||
` Web origins: *`,
|
||||
` Login Theme: ${buildOptions.themeName}`,
|
||||
` Save (button at the bottom of the page)`,
|
||||
``,
|
||||
`- Go to 👉 https://www.keycloak.org/app/ 👈 Click "Save" then "Sign in". You should see your login page`,
|
||||
`- Got to 👉 http://localhost:8080/realms/myrealm/account 👈 to see your account theme`,
|
||||
|
@ -11,10 +11,6 @@ export type ParsedPackageJson = {
|
||||
version?: string;
|
||||
homepage?: string;
|
||||
keycloakify?: {
|
||||
/** @deprecated: use extraLoginPages instead */
|
||||
extraPages?: string[];
|
||||
extraLoginPages?: string[];
|
||||
extraAccountPages?: string[];
|
||||
extraThemeProperties?: string[];
|
||||
areAppAndKeycloakServerSharingSameDomain?: boolean;
|
||||
artifactId?: string;
|
||||
@ -23,8 +19,8 @@ export type ParsedPackageJson = {
|
||||
keycloakVersionDefaultAssets?: string;
|
||||
reactAppBuildDirPath?: string;
|
||||
keycloakifyBuildDirPath?: string;
|
||||
customUserAttributes?: string[];
|
||||
themeName?: string;
|
||||
extraThemeNames?: string[];
|
||||
};
|
||||
};
|
||||
|
||||
@ -34,9 +30,6 @@ export const zParsedPackageJson = z.object({
|
||||
"homepage": z.string().optional(),
|
||||
"keycloakify": z
|
||||
.object({
|
||||
"extraPages": z.array(z.string()).optional(),
|
||||
"extraLoginPages": z.array(z.string()).optional(),
|
||||
"extraAccountPages": z.array(z.string()).optional(),
|
||||
"extraThemeProperties": z.array(z.string()).optional(),
|
||||
"areAppAndKeycloakServerSharingSameDomain": z.boolean().optional(),
|
||||
"artifactId": z.string().optional(),
|
||||
@ -45,8 +38,8 @@ export const zParsedPackageJson = z.object({
|
||||
"keycloakVersionDefaultAssets": z.string().optional(),
|
||||
"reactAppBuildDirPath": z.string().optional(),
|
||||
"keycloakifyBuildDirPath": z.string().optional(),
|
||||
"customUserAttributes": z.array(z.string()).optional(),
|
||||
"themeName": z.string().optional()
|
||||
"themeName": z.string().optional(),
|
||||
"extraThemeNames": z.array(z.string()).optional()
|
||||
})
|
||||
.optional()
|
||||
});
|
||||
|
@ -2,4 +2,4 @@ import { pathJoin } from "./tools/pathJoin";
|
||||
|
||||
export const basenameOfKeycloakDirInPublicDir = "keycloak-resources";
|
||||
export const resourcesDirPathRelativeToPublicDir = pathJoin(basenameOfKeycloakDirInPublicDir, "resources");
|
||||
export const resourcesCommonDirPathRelativeToPublicDir = pathJoin(basenameOfKeycloakDirInPublicDir, "resources_common");
|
||||
export const resourcesCommonDirPathRelativeToPublicDir = pathJoin(resourcesDirPathRelativeToPublicDir, "resources_common");
|
||||
|
@ -1,27 +1,32 @@
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
|
||||
/** List all files in a given directory return paths relative to the dir_path */
|
||||
export const crawl = (() => {
|
||||
const crawlRec = (dir_path: string, paths: string[]) => {
|
||||
for (const file_name of fs.readdirSync(dir_path)) {
|
||||
const file_path = path.join(dir_path, file_name);
|
||||
const crawlRec = (dir_path: string, paths: string[]) => {
|
||||
for (const file_name of fs.readdirSync(dir_path)) {
|
||||
const file_path = path.join(dir_path, file_name);
|
||||
|
||||
if (fs.lstatSync(file_path).isDirectory()) {
|
||||
crawlRec(file_path, paths);
|
||||
if (fs.lstatSync(file_path).isDirectory()) {
|
||||
crawlRec(file_path, paths);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
paths.push(file_path);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
return function crawl(dir_path: string): string[] {
|
||||
const paths: string[] = [];
|
||||
paths.push(file_path);
|
||||
}
|
||||
};
|
||||
|
||||
crawlRec(dir_path, paths);
|
||||
/** List all files in a given directory return paths relative to the dir_path */
|
||||
export function crawl(params: { dirPath: string; returnedPathsType: "absolute" | "relative to dirPath" }): string[] {
|
||||
const { dirPath, returnedPathsType } = params;
|
||||
|
||||
return paths.map(file_path => path.relative(dir_path, file_path));
|
||||
};
|
||||
})();
|
||||
const filePaths: string[] = [];
|
||||
|
||||
crawlRec(dirPath, filePaths);
|
||||
|
||||
switch (returnedPathsType) {
|
||||
case "absolute":
|
||||
return filePaths;
|
||||
case "relative to dirPath":
|
||||
return filePaths.map(filePath => path.relative(dirPath, filePath));
|
||||
}
|
||||
}
|
||||
|
@ -1,9 +1,9 @@
|
||||
import { exec as execCallback } from "child_process";
|
||||
import { createHash } from "crypto";
|
||||
import { mkdir, stat, writeFile } from "fs/promises";
|
||||
import { mkdir, readFile, stat, writeFile } from "fs/promises";
|
||||
import fetch, { type FetchOptions } from "make-fetch-happen";
|
||||
import { dirname as pathDirname, join as pathJoin } from "path";
|
||||
import { assert } from "tsafe";
|
||||
import { dirname as pathDirname, join as pathJoin, resolve as pathResolve, sep as pathSep } from "path";
|
||||
import { assert } from "tsafe/assert";
|
||||
import { promisify } from "util";
|
||||
import { getProjectRoot } from "./getProjectRoot";
|
||||
import { transformCodebase } from "./transformCodebase";
|
||||
@ -25,32 +25,94 @@ async function exists(path: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function ensureArray<T>(arg0: T | T[]) {
|
||||
return Array.isArray(arg0) ? arg0 : typeof arg0 === "undefined" ? [] : [arg0];
|
||||
}
|
||||
|
||||
function ensureSingleOrNone<T>(arg0: T | T[]) {
|
||||
if (!Array.isArray(arg0)) return arg0;
|
||||
if (arg0.length === 0) return undefined;
|
||||
if (arg0.length === 1) return arg0[0];
|
||||
throw new Error("Illegal configuration, expected a single value but found multiple: " + arg0.map(String).join(", "));
|
||||
}
|
||||
|
||||
type NPMConfig = Record<string, string | string[]>;
|
||||
|
||||
const npmConfigReducer = (cfg: NPMConfig, [key, value]: [string, string]) =>
|
||||
key in cfg ? { ...cfg, [key]: [...ensureArray(cfg[key]), value] } : { ...cfg, [key]: value };
|
||||
|
||||
/**
|
||||
* Get npm configuration as map
|
||||
*/
|
||||
async function getNmpConfig(): Promise<Record<string, string>> {
|
||||
const { stdout } = await exec("npm config get", { encoding: "utf8" });
|
||||
async function getNmpConfig() {
|
||||
return readNpmConfig().then(parseNpmConfig);
|
||||
}
|
||||
|
||||
function readNpmConfig(): Promise<string> {
|
||||
return (async function callee(depth: number): Promise<string> {
|
||||
const cwd = pathResolve(pathJoin(...[process.cwd(), ...Array(depth).fill("..")]));
|
||||
|
||||
let stdout: string;
|
||||
|
||||
try {
|
||||
stdout = await exec("npm config get", { "encoding": "utf8", cwd }).then(({ stdout }) => stdout);
|
||||
} catch (error) {
|
||||
console.log(String(error), error);
|
||||
|
||||
if (String(error).includes("ENOWORKSPACES")) {
|
||||
assert(cwd !== pathSep);
|
||||
|
||||
return callee(depth + 1);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
return stdout;
|
||||
})(0);
|
||||
}
|
||||
|
||||
function parseNpmConfig(stdout: string) {
|
||||
return stdout
|
||||
.split("\n")
|
||||
.filter(line => !line.startsWith(";"))
|
||||
.map(line => line.trim())
|
||||
.map(line => line.split("=", 2))
|
||||
.reduce((cfg, [key, value]) => ({ ...cfg, [key]: value }), {});
|
||||
.map(line => line.split("=", 2) as [string, string])
|
||||
.reduce(npmConfigReducer, {} as NPMConfig);
|
||||
}
|
||||
|
||||
function maybeBoolean(arg0: string | undefined) {
|
||||
return typeof arg0 === "undefined" ? undefined : Boolean(arg0);
|
||||
}
|
||||
|
||||
function chunks<T>(arr: T[], size: number = 2) {
|
||||
return arr.map((_, i) => i % size == 0 && arr.slice(i, i + size)).filter(Boolean) as T[][];
|
||||
}
|
||||
|
||||
async function readCafile(cafile: string) {
|
||||
const cafileContent = await readFile(cafile, "utf-8");
|
||||
return chunks(cafileContent.split(/(-----END CERTIFICATE-----)/), 2).map(ca => ca.join("").replace(/^\n/, "").replace(/\n/g, "\\n"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get proxy configuration from npm config files. Note that we don't care about
|
||||
* Get proxy and ssl configuration from npm config files. Note that we don't care about
|
||||
* proxy config in env vars, because make-fetch-happen will do that for us.
|
||||
*
|
||||
* @returns proxy configuration
|
||||
*/
|
||||
async function getNpmProxyConfig(): Promise<Pick<FetchOptions, "proxy" | "noProxy">> {
|
||||
async function getFetchOptions(): Promise<Pick<FetchOptions, "proxy" | "noProxy" | "strictSSL" | "ca" | "cert">> {
|
||||
const cfg = await getNmpConfig();
|
||||
|
||||
const proxy = cfg["https-proxy"] ?? cfg["proxy"];
|
||||
const proxy = ensureSingleOrNone(cfg["https-proxy"] ?? cfg["proxy"]);
|
||||
const noProxy = cfg["noproxy"] ?? cfg["no-proxy"];
|
||||
const strictSSL = maybeBoolean(ensureSingleOrNone(cfg["strict-ssl"]));
|
||||
const cert = cfg["cert"];
|
||||
const ca = ensureArray(cfg["ca"] ?? cfg["ca[]"]);
|
||||
const cafile = ensureSingleOrNone(cfg["cafile"]);
|
||||
|
||||
return { proxy, noProxy };
|
||||
if (typeof cafile !== "undefined" && cafile !== "null") ca.push(...(await readCafile(cafile)));
|
||||
|
||||
return { proxy, noProxy, strictSSL, cert, ca: ca.length === 0 ? undefined : ca };
|
||||
}
|
||||
|
||||
export async function downloadAndUnzip(params: { url: string; destDirPath: string; pathOfDirToExtractInArchive?: string }) {
|
||||
@ -63,8 +125,8 @@ export async function downloadAndUnzip(params: { url: string; destDirPath: strin
|
||||
const extractDirPath = pathJoin(cacheRoot, "keycloakify", "unzip", `_${downloadHash}`);
|
||||
|
||||
if (!(await exists(zipFilePath))) {
|
||||
const proxyOpts = await getNpmProxyConfig();
|
||||
const response = await fetch(url, proxyOpts);
|
||||
const opts = await getFetchOptions();
|
||||
const response = await fetch(url, opts);
|
||||
await mkdir(pathDirname(zipFilePath), { "recursive": true });
|
||||
/**
|
||||
* The correct way to fix this is to upgrade node-fetch beyond 3.2.5
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { dirname, relative, sep } from "path";
|
||||
import { dirname, relative, sep, join } from "path";
|
||||
import { createWriteStream } from "fs";
|
||||
|
||||
import walk from "./walk";
|
||||
@ -48,8 +48,12 @@ export async function jarStream({ groupId, artifactId, version, asyncPathGenerat
|
||||
for await (const entry of asyncPathGeneratorFn()) {
|
||||
if ("buffer" in entry) {
|
||||
zipFile.addBuffer(entry.buffer, entry.zipPath);
|
||||
} else if ("fsPath" in entry && !entry.fsPath.endsWith(sep)) {
|
||||
zipFile.addFile(entry.fsPath, entry.zipPath);
|
||||
} else if ("fsPath" in entry) {
|
||||
if (entry.fsPath.endsWith(sep)) {
|
||||
zipFile.addEmptyDirectory(entry.zipPath);
|
||||
} else {
|
||||
zipFile.addFile(entry.fsPath, entry.zipPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -65,15 +69,23 @@ export async function jarStream({ groupId, artifactId, version, asyncPathGenerat
|
||||
* Create a jar archive, using the resources found at `rootPath` (a directory) and write the
|
||||
* archive to `targetPath` (a file). Use `groupId`, `artifactId` and `version` to define
|
||||
* the contents of the pom.properties file which is going to be added to the archive.
|
||||
* The root directory is expectedto have a conventional maven/gradle folder structure with a
|
||||
* single `pom.xml` file at the root and a `src/main/resources` directory containing all
|
||||
* application resources.
|
||||
*/
|
||||
export default async function jar({ groupId, artifactId, version, rootPath, targetPath }: JarArgs) {
|
||||
await mkdir(dirname(targetPath), { recursive: true });
|
||||
|
||||
const asyncPathGeneratorFn = async function* (): ZipEntryGenerator {
|
||||
for await (const fsPath of walk(rootPath)) {
|
||||
const zipPath = relative(rootPath, fsPath).split(sep).join("/");
|
||||
const resourcesPath = join(rootPath, "src", "main", "resources");
|
||||
for await (const fsPath of walk(resourcesPath)) {
|
||||
const zipPath = relative(resourcesPath, fsPath).split(sep).join("/");
|
||||
yield { fsPath, zipPath };
|
||||
}
|
||||
yield {
|
||||
fsPath: join(rootPath, "pom.xml"),
|
||||
zipPath: `META-INF/maven/${groupId}/${artifactId}/pom.xml`
|
||||
};
|
||||
};
|
||||
|
||||
const zipFile = await jarStream({ groupId, artifactId, version, asyncPathGeneratorFn });
|
||||
|
@ -20,12 +20,12 @@ export function transformCodebase(params: { srcDirPath: string; destDirPath: str
|
||||
}))
|
||||
} = params;
|
||||
|
||||
for (const file_relative_path of crawl(srcDirPath)) {
|
||||
for (const file_relative_path of crawl({ "dirPath": srcDirPath, "returnedPathsType": "relative to dirPath" })) {
|
||||
const filePath = path.join(srcDirPath, file_relative_path);
|
||||
|
||||
const transformSourceCodeResult = transformSourceCode({
|
||||
"sourceCode": fs.readFileSync(filePath),
|
||||
"filePath": path.join(srcDirPath, file_relative_path)
|
||||
filePath
|
||||
});
|
||||
|
||||
if (transformSourceCodeResult === undefined) {
|
||||
|
@ -9,7 +9,7 @@ function populateTemplate(strings: TemplateStringsArray, ...args: unknown[]) {
|
||||
if (strings[i]) {
|
||||
chunks.push(strings[i]);
|
||||
// remember last indent of the string portion
|
||||
lastStringLineLength = strings[i].split("\n").at(-1)?.length ?? 0;
|
||||
lastStringLineLength = strings[i].split("\n").slice(-1)[0]?.length ?? 0;
|
||||
}
|
||||
if (args[i]) {
|
||||
// if the interpolation value has newlines, indent the interpolation values
|
||||
|
@ -27,6 +27,7 @@ const UpdateUserProfile = lazy(() => import("keycloakify/login/pages/UpdateUserP
|
||||
const IdpReviewUserProfile = lazy(() => import("keycloakify/login/pages/IdpReviewUserProfile"));
|
||||
const UpdateEmail = lazy(() => import("keycloakify/login/pages/UpdateEmail"));
|
||||
const SelectAuthenticator = lazy(() => import("keycloakify/login/pages/SelectAuthenticator"));
|
||||
const SamlPostForm = lazy(() => import("keycloakify/login/pages/SamlPostForm"));
|
||||
|
||||
export default function Fallback(props: PageProps<KcContext, I18n>) {
|
||||
const { kcContext, ...rest } = props;
|
||||
@ -81,6 +82,8 @@ export default function Fallback(props: PageProps<KcContext, I18n>) {
|
||||
return <UpdateEmail kcContext={kcContext} {...rest} />;
|
||||
case "select-authenticator.ftl":
|
||||
return <SelectAuthenticator kcContext={kcContext} {...rest} />;
|
||||
case "saml-post-form.ftl":
|
||||
return <SamlPostForm kcContext={kcContext} {...rest} />;
|
||||
}
|
||||
assert<Equals<typeof kcContext, never>>(false);
|
||||
})()}
|
||||
|
@ -211,7 +211,8 @@ const keycloakifyExtraMessages = {
|
||||
"shouldBeDifferent": "{0} should be different to {1}",
|
||||
"shouldMatchPattern": "Pattern should match: `/{0}/`",
|
||||
"mustBeAnInteger": "Must be an integer",
|
||||
"notAValidOption": "Not a valid option"
|
||||
"notAValidOption": "Not a valid option",
|
||||
"selectAnOption": "Select an option"
|
||||
},
|
||||
"fr": {
|
||||
/* spell-checker: disable */
|
||||
@ -223,7 +224,8 @@ const keycloakifyExtraMessages = {
|
||||
|
||||
"logoutConfirmTitle": "Déconnexion",
|
||||
"logoutConfirmHeader": "Êtes-vous sûr(e) de vouloir vous déconnecter ?",
|
||||
"doLogout": "Se déconnecter"
|
||||
"doLogout": "Se déconnecter",
|
||||
"selectAnOption": "Sélectionner une option"
|
||||
/* spell-checker: enable */
|
||||
}
|
||||
};
|
||||
|
@ -5,6 +5,7 @@ export default Fallback;
|
||||
export { useDownloadTerms } from "keycloakify/login/lib/useDownloadTerms";
|
||||
export { getKcContext } from "keycloakify/login/kcContext/getKcContext";
|
||||
export { createGetKcContext } from "keycloakify/login/kcContext/createGetKcContext";
|
||||
export type { LoginThemePageId as PageId } from "keycloakify/bin/keycloakify/generateFtl";
|
||||
export { createUseI18n } from "keycloakify/login/i18n/i18n";
|
||||
|
||||
export type { PageProps } from "keycloakify/login/pages/PageProps";
|
||||
|
@ -1,4 +1,4 @@
|
||||
import type { LoginThemePageId } from "keycloakify/bin/keycloakify/generateFtl";
|
||||
import type { LoginThemePageId, ThemeType } from "keycloakify/bin/keycloakify/generateFtl";
|
||||
import { assert } from "tsafe/assert";
|
||||
import type { Equals } from "tsafe";
|
||||
import type { MessageKey } from "../i18n/i18n";
|
||||
@ -32,11 +32,14 @@ export type KcContext =
|
||||
| KcContext.UpdateUserProfile
|
||||
| KcContext.IdpReviewUserProfile
|
||||
| KcContext.UpdateEmail
|
||||
| KcContext.SelectAuthenticator;
|
||||
| KcContext.SelectAuthenticator
|
||||
| KcContext.SamlPostForm;
|
||||
|
||||
export declare namespace KcContext {
|
||||
export type Common = {
|
||||
keycloakifyVersion: string;
|
||||
themeType: "login";
|
||||
themeName: string;
|
||||
url: {
|
||||
loginAction: string;
|
||||
resourcesPath: string;
|
||||
@ -78,13 +81,48 @@ export declare namespace KcContext {
|
||||
};
|
||||
isAppInitiatedAction: boolean;
|
||||
messagesPerField: {
|
||||
printIfExists: <T>(fieldName: string, x: T) => T | undefined;
|
||||
/**
|
||||
* Return text if message for given field exists. Useful eg. to add css styles for fields with message.
|
||||
*
|
||||
* @param fieldName to check for
|
||||
* @param text to return
|
||||
* @return text if message exists for given field, else undefined
|
||||
*/
|
||||
printIfExists: <T extends string>(fieldName: string, text: T) => T | undefined;
|
||||
/**
|
||||
* Check if exists error message for given fields
|
||||
*
|
||||
* @param fields
|
||||
* @return boolean
|
||||
*/
|
||||
existsError: (fieldName: string) => boolean;
|
||||
/**
|
||||
* Get message for given field.
|
||||
*
|
||||
* @param fieldName
|
||||
* @return message text or empty string
|
||||
*/
|
||||
get: (fieldName: string) => string;
|
||||
/**
|
||||
* Check if message for given field exists
|
||||
*
|
||||
* @param field
|
||||
* @return boolean
|
||||
*/
|
||||
exists: (fieldName: string) => boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export type SamlPostForm = Common & {
|
||||
pageId: "saml-post-form.ftl";
|
||||
samlPost: {
|
||||
url: string;
|
||||
SAMLRequest?: string;
|
||||
SAMLResponse?: string;
|
||||
relayState?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type Login = Common & {
|
||||
pageId: "login.ftl";
|
||||
url: {
|
||||
@ -543,4 +581,15 @@ export declare namespace Validators {
|
||||
};
|
||||
}
|
||||
|
||||
assert<Equals<KcContext["pageId"], LoginThemePageId>>();
|
||||
{
|
||||
type Got = KcContext["pageId"];
|
||||
type Expected = LoginThemePageId;
|
||||
|
||||
type OnlyInGot = Exclude<Got, Expected>;
|
||||
type OnlyInExpected = Exclude<Expected, Got>;
|
||||
|
||||
assert<Equals<OnlyInGot, never>>();
|
||||
assert<Equals<OnlyInExpected, never>>();
|
||||
}
|
||||
|
||||
assert<KcContext["themeType"] extends ThemeType ? true : false>();
|
||||
|
@ -11,7 +11,6 @@ import { pathJoin } from "keycloakify/bin/tools/pathJoin";
|
||||
import { pathBasename } from "keycloakify/tools/pathBasename";
|
||||
import { resourcesCommonDirPathRelativeToPublicDir } from "keycloakify/bin/mockTestingResourcesPath";
|
||||
import { symToStr } from "tsafe/symToStr";
|
||||
import { loginThemePageIds } from "keycloakify/bin/keycloakify/generateFtl/pageId";
|
||||
|
||||
export function createGetKcContext<KcContextExtension extends { pageId: string } = never>(params?: {
|
||||
mockData?: readonly DeepPartial<ExtendKcContext<KcContextExtension>>[];
|
||||
@ -145,7 +144,7 @@ export function createGetKcContext<KcContextExtension extends { pageId: string }
|
||||
return { "kcContext": undefined as any };
|
||||
}
|
||||
|
||||
if (id<readonly string[]>(loginThemePageIds).indexOf(realKcContext.pageId) < 0 && !("login" in realKcContext)) {
|
||||
if (realKcContext.themeType !== "login") {
|
||||
return { "kcContext": undefined as any };
|
||||
}
|
||||
|
||||
|
@ -3,8 +3,10 @@ import type { KcContext, Attribute } from "./KcContext";
|
||||
import { resourcesCommonDirPathRelativeToPublicDir, resourcesDirPathRelativeToPublicDir } from "keycloakify/bin/mockTestingResourcesPath";
|
||||
import { pathJoin } from "keycloakify/bin/tools/pathJoin";
|
||||
import { id } from "tsafe/id";
|
||||
import { assert, type Equals } from "tsafe/assert";
|
||||
import type { LoginThemePageId } from "keycloakify/bin/keycloakify/generateFtl";
|
||||
|
||||
const PUBLIC_URL = process.env["PUBLIC_URL"] ?? "/";
|
||||
const PUBLIC_URL = (typeof process !== "object" ? undefined : process.env?.["PUBLIC_URL"]) || "/";
|
||||
|
||||
const attributes: Attribute[] = [
|
||||
{
|
||||
@ -102,6 +104,8 @@ const attributesByName = Object.fromEntries(attributes.map(attribute => [attribu
|
||||
|
||||
export const kcContextCommonMock: KcContext.Common = {
|
||||
"keycloakifyVersion": "0.0.0",
|
||||
"themeType": "login",
|
||||
"themeName": "my-theme-name",
|
||||
"url": {
|
||||
"loginAction": "#",
|
||||
"resourcesPath": pathJoin(PUBLIC_URL, resourcesDirPathRelativeToPublicDir),
|
||||
@ -243,7 +247,7 @@ const loginUrl = {
|
||||
"registrationUrl": "/auth/realms/myrealm/login-actions/registration?client_id=account&tab_id=HoAx28ja4xg"
|
||||
};
|
||||
|
||||
export const kcContextMocks: KcContext[] = [
|
||||
export const kcContextMocks = [
|
||||
id<KcContext.Login>({
|
||||
...kcContextCommonMock,
|
||||
"pageId": "login.ftl",
|
||||
@ -519,5 +523,27 @@ export const kcContextMocks: KcContext[] = [
|
||||
}
|
||||
]
|
||||
}
|
||||
}),
|
||||
id<KcContext.SamlPostForm>({
|
||||
...kcContextCommonMock,
|
||||
pageId: "saml-post-form.ftl",
|
||||
"samlPost": {
|
||||
"url": ""
|
||||
}
|
||||
}),
|
||||
id<KcContext.LoginPageExpired>({
|
||||
...kcContextCommonMock,
|
||||
pageId: "login-page-expired.ftl"
|
||||
})
|
||||
];
|
||||
|
||||
{
|
||||
type Got = (typeof kcContextMocks)[number]["pageId"];
|
||||
type Expected = LoginThemePageId;
|
||||
|
||||
type OnlyInGot = Exclude<Got, Expected>;
|
||||
type OnlyInExpected = Exclude<Expected, Got>;
|
||||
|
||||
assert<Equals<OnlyInGot, never>>();
|
||||
assert<Equals<OnlyInExpected, never>>();
|
||||
}
|
||||
|
@ -25,16 +25,7 @@ export function useFormValidation(params: {
|
||||
passwordValidators?: Validators;
|
||||
i18n: I18n;
|
||||
}) {
|
||||
const {
|
||||
kcContext,
|
||||
passwordValidators = {
|
||||
"length": {
|
||||
"ignore.empty.value": true,
|
||||
"min": "4"
|
||||
}
|
||||
},
|
||||
i18n
|
||||
} = params;
|
||||
const { kcContext, passwordValidators = {}, i18n } = params;
|
||||
|
||||
const attributesWithPassword = useMemo(
|
||||
() =>
|
||||
@ -211,7 +202,7 @@ function useGetErrors(params: {
|
||||
const { value: defaultValue, validators } = attributes.find(attribute => attribute.name === name)!;
|
||||
|
||||
block: {
|
||||
if (defaultValue !== value) {
|
||||
if ((defaultValue ?? "") !== value) {
|
||||
break block;
|
||||
}
|
||||
|
||||
|
@ -123,7 +123,7 @@ export default function LoginUpdatePassword(props: PageProps<Extract<KcContext,
|
||||
getClassName("kcButtonLargeClass")
|
||||
)}
|
||||
type="submit"
|
||||
defaultValue={msgStr("doSubmit")}
|
||||
value={msgStr("doSubmit")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
@ -11,7 +11,7 @@ export default function LoginVerifyEmail(props: PageProps<Extract<KcContext, { p
|
||||
|
||||
return (
|
||||
<Template {...{ kcContext, i18n, doUseDefaultCss, classes }} displayMessage={false} headerNode={msg("emailVerifyTitle")}>
|
||||
<p className="instruction">{msg("emailVerifyInstruction1", user?.email)}</p>
|
||||
<p className="instruction">{msg("emailVerifyInstruction1", user?.email ?? "")}</p>
|
||||
<p className="instruction">
|
||||
{msg("emailVerifyInstruction2")}
|
||||
<br />
|
||||
|
@ -14,11 +14,13 @@ export default function RegisterUserProfile(props: PageProps<Extract<KcContext,
|
||||
classes
|
||||
});
|
||||
|
||||
const { url, messagesPerField, recaptchaRequired, recaptchaSiteKey } = kcContext;
|
||||
const { url, messagesPerField, recaptchaRequired, recaptchaSiteKey, realm } = kcContext;
|
||||
|
||||
realm.registrationEmailAsUsername;
|
||||
|
||||
const { msg, msgStr } = i18n;
|
||||
|
||||
const [isFomSubmittable, setIsFomSubmittable] = useState(false);
|
||||
const [isFormSubmittable, setIsFormSubmittable] = useState(false);
|
||||
|
||||
return (
|
||||
<Template
|
||||
@ -30,7 +32,7 @@ export default function RegisterUserProfile(props: PageProps<Extract<KcContext,
|
||||
<form id="kc-register-form" className={getClassName("kcFormClass")} action={url.registrationAction} method="post">
|
||||
<UserProfileFormFields
|
||||
kcContext={kcContext}
|
||||
onIsFormSubmittableValueChange={setIsFomSubmittable}
|
||||
onIsFormSubmittableValueChange={setIsFormSubmittable}
|
||||
i18n={i18n}
|
||||
getClassName={getClassName}
|
||||
/>
|
||||
@ -60,7 +62,7 @@ export default function RegisterUserProfile(props: PageProps<Extract<KcContext,
|
||||
)}
|
||||
type="submit"
|
||||
value={msgStr("doRegister")}
|
||||
disabled={!isFomSubmittable}
|
||||
disabled={!isFormSubmittable}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
42
src/login/pages/SamlPostForm.tsx
Normal file
42
src/login/pages/SamlPostForm.tsx
Normal file
@ -0,0 +1,42 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { PageProps } from "keycloakify/login/pages/PageProps";
|
||||
import type { KcContext } from "../kcContext";
|
||||
import type { I18n } from "../i18n";
|
||||
|
||||
export default function SamlPostForm(props: PageProps<Extract<KcContext, { pageId: "saml-post-form.ftl" }>, I18n>) {
|
||||
const { kcContext, i18n, doUseDefaultCss, Template, classes } = props;
|
||||
|
||||
const { msgStr, msg } = i18n;
|
||||
|
||||
const { samlPost } = kcContext;
|
||||
|
||||
const [htmlFormElement, setHtmlFormElement] = useState<HTMLFormElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (htmlFormElement === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Storybook
|
||||
if (samlPost.url === "") {
|
||||
alert("In a real Keycloak the user would be redirected immediately");
|
||||
return;
|
||||
}
|
||||
|
||||
htmlFormElement.submit();
|
||||
}, [htmlFormElement]);
|
||||
return (
|
||||
<Template {...{ kcContext, i18n, doUseDefaultCss, classes }} displayMessage={false} headerNode={msg("saml.post-form.title")}>
|
||||
<p>{msg("saml.post-form.message")}</p>
|
||||
<form name="saml-post-binding" method="post" action={samlPost.url} ref={setHtmlFormElement}>
|
||||
{samlPost.SAMLRequest && <input type="hidden" name="SAMLRequest" value={samlPost.SAMLRequest} />}
|
||||
{samlPost.SAMLResponse && <input type="hidden" name="SAMLResponse" value={samlPost.SAMLResponse} />}
|
||||
{samlPost.relayState && <input type="hidden" name="RelayState" value={samlPost.relayState} />}
|
||||
<noscript>
|
||||
<p>{msg("saml.post-form.js-disabled")}</p>
|
||||
<input type="submit" value={msgStr("doContinue")} />
|
||||
</noscript>
|
||||
</form>
|
||||
</Template>
|
||||
);
|
||||
}
|
@ -7,6 +7,9 @@ import type { PageProps } from "keycloakify/login/pages/PageProps";
|
||||
import { useGetClassName } from "keycloakify/login/lib/useGetClassName";
|
||||
import type { KcContext } from "../kcContext";
|
||||
import type { I18n } from "../i18n";
|
||||
import { assert } from "tsafe/assert";
|
||||
import { is } from "tsafe/is";
|
||||
import { typeGuard } from "tsafe/typeGuard";
|
||||
|
||||
export default function WebauthnAuthenticate(props: PageProps<Extract<KcContext, { pageId: "webauthn-authenticate.ftl" }>, I18n>) {
|
||||
const { kcContext, i18n, doUseDefaultCss, Template, classes } = props;
|
||||
@ -21,10 +24,24 @@ export default function WebauthnAuthenticate(props: PageProps<Extract<KcContext,
|
||||
const createTimeout = Number(kcContext.createTimeout);
|
||||
const isUserIdentified = kcContext.isUserIdentified == "true";
|
||||
|
||||
const formElementRef = useRef<HTMLFormElement>(null);
|
||||
|
||||
const webAuthnAuthenticate = useConstCallback(async () => {
|
||||
if (!isUserIdentified) {
|
||||
return;
|
||||
}
|
||||
|
||||
const submitForm = async (): Promise<void> => {
|
||||
const formElement = formElementRef.current;
|
||||
|
||||
if (formElement === null) {
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
return submitForm();
|
||||
}
|
||||
|
||||
formElement.submit();
|
||||
};
|
||||
|
||||
const allowCredentials = authenticators.authenticators.map(
|
||||
authenticator =>
|
||||
({
|
||||
@ -57,30 +74,36 @@ export default function WebauthnAuthenticate(props: PageProps<Extract<KcContext,
|
||||
}
|
||||
|
||||
try {
|
||||
const resultRaw = await navigator.credentials.get({ publicKey });
|
||||
if (!resultRaw || resultRaw.type != "public-key") return;
|
||||
const result = resultRaw as PublicKeyCredential;
|
||||
if (!("authenticatorData" in result.response)) return;
|
||||
const response = result.response as AuthenticatorAssertionResponse;
|
||||
const result = await navigator.credentials.get({ publicKey });
|
||||
if (!result || result.type != "public-key") {
|
||||
return;
|
||||
}
|
||||
assert(is<PublicKeyCredential>(result));
|
||||
if (!("authenticatorData" in result.response)) {
|
||||
return;
|
||||
}
|
||||
const response = result.response;
|
||||
|
||||
const clientDataJSON = response.clientDataJSON;
|
||||
|
||||
assert(
|
||||
typeGuard<AuthenticatorAssertionResponse>(response, "signature" in response && response.authenticatorData instanceof ArrayBuffer),
|
||||
"response not an AuthenticatorAssertionResponse"
|
||||
);
|
||||
|
||||
const authenticatorData = response.authenticatorData;
|
||||
const signature = response.signature;
|
||||
|
||||
setClientDataJSON(base64url.stringify(new Uint8Array(clientDataJSON), { pad: false }));
|
||||
setAuthenticatorData(base64url.stringify(new Uint8Array(authenticatorData), { pad: false }));
|
||||
setSignature(base64url.stringify(new Uint8Array(signature), { pad: false }));
|
||||
setClientDataJSON(base64url.stringify(new Uint8Array(clientDataJSON), { "pad": false }));
|
||||
setAuthenticatorData(base64url.stringify(new Uint8Array(authenticatorData), { "pad": false }));
|
||||
setSignature(base64url.stringify(new Uint8Array(signature), { "pad": false }));
|
||||
setCredentialId(result.id);
|
||||
setUserHandle(base64url.stringify(new Uint8Array(response.userHandle!), { pad: false }));
|
||||
submitForm();
|
||||
setUserHandle(base64url.stringify(new Uint8Array(response.userHandle!), { "pad": false }));
|
||||
} catch (err) {
|
||||
setError(String(err));
|
||||
submitForm();
|
||||
}
|
||||
});
|
||||
|
||||
const webAuthForm = useRef<HTMLFormElement>(null);
|
||||
const submitForm = useConstCallback(() => {
|
||||
webAuthForm.current!.submit();
|
||||
submitForm();
|
||||
});
|
||||
|
||||
const [clientDataJSON, setClientDataJSON] = useState("");
|
||||
@ -93,7 +116,7 @@ export default function WebauthnAuthenticate(props: PageProps<Extract<KcContext,
|
||||
return (
|
||||
<Template {...{ kcContext, i18n, doUseDefaultCss, classes }} headerNode={msg("webauthn-login-title")}>
|
||||
<div id="kc-form-webauthn" className={getClassName("kcFormClass")}>
|
||||
<form id="webauth" action={url.loginAction} ref={webAuthForm} method="post">
|
||||
<form id="webauth" action={url.loginAction} ref={formElementRef} method="post">
|
||||
<input type="hidden" id="clientDataJSON" name="clientDataJSON" value={clientDataJSON} />
|
||||
<input type="hidden" id="authenticatorData" name="authenticatorData" value={authenticatorData} />
|
||||
<input type="hidden" id="signature" name="signature" value={signature} />
|
||||
|
@ -17,7 +17,7 @@ export type UserProfileFormFieldsProps = {
|
||||
export function UserProfileFormFields(props: UserProfileFormFieldsProps) {
|
||||
const { kcContext, onIsFormSubmittableValueChange, i18n, getClassName, BeforeField, AfterField } = props;
|
||||
|
||||
const { advancedMsg } = i18n;
|
||||
const { advancedMsg, msg } = i18n;
|
||||
|
||||
const {
|
||||
formValidationState: { fieldStateByAttributeName, isFormSubmittable },
|
||||
@ -98,11 +98,16 @@ export function UserProfileFormFields(props: UserProfileFormFieldsProps) {
|
||||
}
|
||||
value={value}
|
||||
>
|
||||
{options.options.map(option => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
<>
|
||||
<option value="" selected disabled hidden>
|
||||
{msg("selectAnOption")}
|
||||
</option>
|
||||
))}
|
||||
{options.options.map(option => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</>
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
@ -22,3 +22,10 @@ const meta: ComponentMeta<any> = {
|
||||
export default meta;
|
||||
|
||||
export const Default = () => <PageStory />;
|
||||
export const WithNoMessage = () => (
|
||||
<PageStory
|
||||
kcContext={{
|
||||
message: undefined
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
@ -2,3 +2,8 @@ declare module "*.png" {
|
||||
const _default: string;
|
||||
export default _default;
|
||||
}
|
||||
|
||||
declare module "*.md" {
|
||||
const _default: string;
|
||||
export default _default;
|
||||
}
|
@ -2,6 +2,9 @@ import React, { lazy, Suspense } from "react";
|
||||
import Fallback from "../../dist/login";
|
||||
import type { KcContext } from "./kcContext";
|
||||
import { useI18n } from "./i18n";
|
||||
import { useDownloadTerms } from "../../dist/login/lib/useDownloadTerms";
|
||||
import tos_en_url from "./tos_en.md";
|
||||
import tos_fr_url from "./tos_fr.md";
|
||||
|
||||
const DefaultTemplate = lazy(() => import("../../dist/login/Template"));
|
||||
|
||||
@ -10,6 +13,26 @@ export default function KcApp(props: { kcContext: KcContext }) {
|
||||
|
||||
const i18n = useI18n({ kcContext });
|
||||
|
||||
useDownloadTerms({
|
||||
"kcContext": kcContext as any,
|
||||
"downloadTermMarkdown": async ({ currentLanguageTag }) => {
|
||||
const resource = (() => {
|
||||
switch (currentLanguageTag) {
|
||||
case "fr":
|
||||
return tos_fr_url;
|
||||
default:
|
||||
return tos_en_url;
|
||||
}
|
||||
})();
|
||||
|
||||
// webpack5 (used via storybook) loads markdown as string, not url
|
||||
if (resource.includes("\n")) return resource;
|
||||
|
||||
const response = await fetch(resource);
|
||||
return response.text();
|
||||
}
|
||||
});
|
||||
|
||||
if (i18n === null) {
|
||||
return null;
|
||||
}
|
||||
|
24
stories/login/pages/IdpReviewUserProfile.stories.tsx
Normal file
24
stories/login/pages/IdpReviewUserProfile.stories.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import React from "react";
|
||||
import type { ComponentMeta } from "@storybook/react";
|
||||
import { createPageStory } from "../createPageStory";
|
||||
|
||||
const pageId = "idp-review-user-profile.ftl";
|
||||
|
||||
const { PageStory } = createPageStory({ pageId });
|
||||
|
||||
const meta: ComponentMeta<any> = {
|
||||
title: `login/${pageId}`,
|
||||
component: PageStory,
|
||||
parameters: {
|
||||
viewMode: "story",
|
||||
previewTabs: {
|
||||
"storybook/docs/panel": {
|
||||
"hidden": true
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
export const Default = () => <PageStory />;
|
24
stories/login/pages/Info.stories.tsx
Normal file
24
stories/login/pages/Info.stories.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import React from "react";
|
||||
import type { ComponentMeta } from "@storybook/react";
|
||||
import { createPageStory } from "../createPageStory";
|
||||
|
||||
const pageId = "info.ftl";
|
||||
|
||||
const { PageStory } = createPageStory({ pageId });
|
||||
|
||||
const meta: ComponentMeta<any> = {
|
||||
title: `login/${pageId}`,
|
||||
component: PageStory,
|
||||
parameters: {
|
||||
viewMode: "story",
|
||||
previewTabs: {
|
||||
"storybook/docs/panel": {
|
||||
"hidden": true
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
export const Default = () => <PageStory />;
|
24
stories/login/pages/LoginConfigTotp.stories.tsx
Normal file
24
stories/login/pages/LoginConfigTotp.stories.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import React from "react";
|
||||
import type { ComponentMeta } from "@storybook/react";
|
||||
import { createPageStory } from "../createPageStory";
|
||||
|
||||
const pageId = "login-config-totp.ftl";
|
||||
|
||||
const { PageStory } = createPageStory({ pageId });
|
||||
|
||||
const meta: ComponentMeta<any> = {
|
||||
title: `login/${pageId}`,
|
||||
component: PageStory,
|
||||
parameters: {
|
||||
viewMode: "story",
|
||||
previewTabs: {
|
||||
"storybook/docs/panel": {
|
||||
"hidden": true
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
export const Default = () => <PageStory />;
|
24
stories/login/pages/LoginIdpLinkConfirm.stories.tsx
Normal file
24
stories/login/pages/LoginIdpLinkConfirm.stories.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import React from "react";
|
||||
import type { ComponentMeta } from "@storybook/react";
|
||||
import { createPageStory } from "../createPageStory";
|
||||
|
||||
const pageId = "login-idp-link-confirm.ftl";
|
||||
|
||||
const { PageStory } = createPageStory({ pageId });
|
||||
|
||||
const meta: ComponentMeta<any> = {
|
||||
title: `login/${pageId}`,
|
||||
component: PageStory,
|
||||
parameters: {
|
||||
viewMode: "story",
|
||||
previewTabs: {
|
||||
"storybook/docs/panel": {
|
||||
"hidden": true
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
export const Default = () => <PageStory />;
|
24
stories/login/pages/LoginIdpLinkEmail.stories.tsx
Normal file
24
stories/login/pages/LoginIdpLinkEmail.stories.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import React from "react";
|
||||
import type { ComponentMeta } from "@storybook/react";
|
||||
import { createPageStory } from "../createPageStory";
|
||||
|
||||
const pageId = "login-idp-link-email.ftl";
|
||||
|
||||
const { PageStory } = createPageStory({ pageId });
|
||||
|
||||
const meta: ComponentMeta<any> = {
|
||||
title: `login/${pageId}`,
|
||||
component: PageStory,
|
||||
parameters: {
|
||||
viewMode: "story",
|
||||
previewTabs: {
|
||||
"storybook/docs/panel": {
|
||||
"hidden": true
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
export const Default = () => <PageStory />;
|
24
stories/login/pages/LoginOtp.stories.tsx
Normal file
24
stories/login/pages/LoginOtp.stories.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import React from "react";
|
||||
import type { ComponentMeta } from "@storybook/react";
|
||||
import { createPageStory } from "../createPageStory";
|
||||
|
||||
const pageId = "login-otp.ftl";
|
||||
|
||||
const { PageStory } = createPageStory({ pageId });
|
||||
|
||||
const meta: ComponentMeta<any> = {
|
||||
title: `login/${pageId}`,
|
||||
component: PageStory,
|
||||
parameters: {
|
||||
viewMode: "story",
|
||||
previewTabs: {
|
||||
"storybook/docs/panel": {
|
||||
"hidden": true
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
export const Default = () => <PageStory />;
|
24
stories/login/pages/LoginPageExpired.stories.tsx
Normal file
24
stories/login/pages/LoginPageExpired.stories.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import React from "react";
|
||||
import type { ComponentMeta } from "@storybook/react";
|
||||
import { createPageStory } from "../createPageStory";
|
||||
|
||||
const pageId = "login-page-expired.ftl";
|
||||
|
||||
const { PageStory } = createPageStory({ pageId });
|
||||
|
||||
const meta: ComponentMeta<any> = {
|
||||
title: `login/${pageId}`,
|
||||
component: PageStory,
|
||||
parameters: {
|
||||
viewMode: "story",
|
||||
previewTabs: {
|
||||
"storybook/docs/panel": {
|
||||
"hidden": true
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
export const Default = () => <PageStory />;
|
24
stories/login/pages/LoginPassword.stories.tsx
Normal file
24
stories/login/pages/LoginPassword.stories.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import React from "react";
|
||||
import type { ComponentMeta } from "@storybook/react";
|
||||
import { createPageStory } from "../createPageStory";
|
||||
|
||||
const pageId = "login-password.ftl";
|
||||
|
||||
const { PageStory } = createPageStory({ pageId });
|
||||
|
||||
const meta: ComponentMeta<any> = {
|
||||
title: `login/${pageId}`,
|
||||
component: PageStory,
|
||||
parameters: {
|
||||
viewMode: "story",
|
||||
previewTabs: {
|
||||
"storybook/docs/panel": {
|
||||
"hidden": true
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
export const Default = () => <PageStory />;
|
24
stories/login/pages/LoginResetPassword.stories.tsx
Normal file
24
stories/login/pages/LoginResetPassword.stories.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import React from "react";
|
||||
import type { ComponentMeta } from "@storybook/react";
|
||||
import { createPageStory } from "../createPageStory";
|
||||
|
||||
const pageId = "login-reset-password.ftl";
|
||||
|
||||
const { PageStory } = createPageStory({ pageId });
|
||||
|
||||
const meta: ComponentMeta<any> = {
|
||||
title: `login/${pageId}`,
|
||||
component: PageStory,
|
||||
parameters: {
|
||||
viewMode: "story",
|
||||
previewTabs: {
|
||||
"storybook/docs/panel": {
|
||||
"hidden": true
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
export const Default = () => <PageStory />;
|
24
stories/login/pages/LoginUpdatePassword.stories.tsx
Normal file
24
stories/login/pages/LoginUpdatePassword.stories.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import React from "react";
|
||||
import type { ComponentMeta } from "@storybook/react";
|
||||
import { createPageStory } from "../createPageStory";
|
||||
|
||||
const pageId = "login-update-password.ftl";
|
||||
|
||||
const { PageStory } = createPageStory({ pageId });
|
||||
|
||||
const meta: ComponentMeta<any> = {
|
||||
title: `login/${pageId}`,
|
||||
component: PageStory,
|
||||
parameters: {
|
||||
viewMode: "story",
|
||||
previewTabs: {
|
||||
"storybook/docs/panel": {
|
||||
"hidden": true
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
export const Default = () => <PageStory />;
|
24
stories/login/pages/LoginUpdateProfile.stories.tsx
Normal file
24
stories/login/pages/LoginUpdateProfile.stories.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import React from "react";
|
||||
import type { ComponentMeta } from "@storybook/react";
|
||||
import { createPageStory } from "../createPageStory";
|
||||
|
||||
const pageId = "login-update-profile.ftl";
|
||||
|
||||
const { PageStory } = createPageStory({ pageId });
|
||||
|
||||
const meta: ComponentMeta<any> = {
|
||||
title: `login/${pageId}`,
|
||||
component: PageStory,
|
||||
parameters: {
|
||||
viewMode: "story",
|
||||
previewTabs: {
|
||||
"storybook/docs/panel": {
|
||||
"hidden": true
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
export const Default = () => <PageStory />;
|
24
stories/login/pages/LoginUsername.stories.tsx
Normal file
24
stories/login/pages/LoginUsername.stories.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import React from "react";
|
||||
import type { ComponentMeta } from "@storybook/react";
|
||||
import { createPageStory } from "../createPageStory";
|
||||
|
||||
const pageId = "login-username.ftl";
|
||||
|
||||
const { PageStory } = createPageStory({ pageId });
|
||||
|
||||
const meta: ComponentMeta<any> = {
|
||||
title: `login/${pageId}`,
|
||||
component: PageStory,
|
||||
parameters: {
|
||||
viewMode: "story",
|
||||
previewTabs: {
|
||||
"storybook/docs/panel": {
|
||||
"hidden": true
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
export const Default = () => <PageStory />;
|
24
stories/login/pages/LoginVerifyEmail.stories.tsx
Normal file
24
stories/login/pages/LoginVerifyEmail.stories.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import React from "react";
|
||||
import type { ComponentMeta } from "@storybook/react";
|
||||
import { createPageStory } from "../createPageStory";
|
||||
|
||||
const pageId = "login-verify-email.ftl";
|
||||
|
||||
const { PageStory } = createPageStory({ pageId });
|
||||
|
||||
const meta: ComponentMeta<any> = {
|
||||
title: `login/${pageId}`,
|
||||
component: PageStory,
|
||||
parameters: {
|
||||
viewMode: "story",
|
||||
previewTabs: {
|
||||
"storybook/docs/panel": {
|
||||
"hidden": true
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
export const Default = () => <PageStory />;
|
24
stories/login/pages/LogoutConfirm.stories.tsx
Normal file
24
stories/login/pages/LogoutConfirm.stories.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import React from "react";
|
||||
import type { ComponentMeta } from "@storybook/react";
|
||||
import { createPageStory } from "../createPageStory";
|
||||
|
||||
const pageId = "logout-confirm.ftl";
|
||||
|
||||
const { PageStory } = createPageStory({ pageId });
|
||||
|
||||
const meta: ComponentMeta<any> = {
|
||||
title: `login/${pageId}`,
|
||||
component: PageStory,
|
||||
parameters: {
|
||||
viewMode: "story",
|
||||
previewTabs: {
|
||||
"storybook/docs/panel": {
|
||||
"hidden": true
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
export const Default = () => <PageStory />;
|
24
stories/login/pages/Register.stories.tsx
Normal file
24
stories/login/pages/Register.stories.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import React from "react";
|
||||
import type { ComponentMeta } from "@storybook/react";
|
||||
import { createPageStory } from "../createPageStory";
|
||||
|
||||
const pageId = "register.ftl";
|
||||
|
||||
const { PageStory } = createPageStory({ pageId });
|
||||
|
||||
const meta: ComponentMeta<any> = {
|
||||
title: `login/${pageId}`,
|
||||
component: PageStory,
|
||||
parameters: {
|
||||
viewMode: "story",
|
||||
previewTabs: {
|
||||
"storybook/docs/panel": {
|
||||
"hidden": true
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
export const Default = () => <PageStory />;
|
24
stories/login/pages/RegisterUserProfile.stories.tsx
Normal file
24
stories/login/pages/RegisterUserProfile.stories.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import React from "react";
|
||||
import type { ComponentMeta } from "@storybook/react";
|
||||
import { createPageStory } from "../createPageStory";
|
||||
|
||||
const pageId = "register-user-profile.ftl";
|
||||
|
||||
const { PageStory } = createPageStory({ pageId });
|
||||
|
||||
const meta: ComponentMeta<any> = {
|
||||
title: `login/${pageId}`,
|
||||
component: PageStory,
|
||||
parameters: {
|
||||
viewMode: "story",
|
||||
previewTabs: {
|
||||
"storybook/docs/panel": {
|
||||
"hidden": true
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
export const Default = () => <PageStory />;
|
24
stories/login/pages/SamlPostForm.stories.tsx
Normal file
24
stories/login/pages/SamlPostForm.stories.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import React from "react";
|
||||
import type { ComponentMeta } from "@storybook/react";
|
||||
import { createPageStory } from "../createPageStory";
|
||||
|
||||
const pageId = "saml-post-form.ftl";
|
||||
|
||||
const { PageStory } = createPageStory({ pageId });
|
||||
|
||||
const meta: ComponentMeta<any> = {
|
||||
title: `login/${pageId}`,
|
||||
component: PageStory,
|
||||
parameters: {
|
||||
viewMode: "story",
|
||||
previewTabs: {
|
||||
"storybook/docs/panel": {
|
||||
"hidden": true
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
export const Default = () => <PageStory />;
|
24
stories/login/pages/SelectAuthenticator.stories.tsx
Normal file
24
stories/login/pages/SelectAuthenticator.stories.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import React from "react";
|
||||
import type { ComponentMeta } from "@storybook/react";
|
||||
import { createPageStory } from "../createPageStory";
|
||||
|
||||
const pageId = "select-authenticator.ftl";
|
||||
|
||||
const { PageStory } = createPageStory({ pageId });
|
||||
|
||||
const meta: ComponentMeta<any> = {
|
||||
title: `login/${pageId}`,
|
||||
component: PageStory,
|
||||
parameters: {
|
||||
viewMode: "story",
|
||||
previewTabs: {
|
||||
"storybook/docs/panel": {
|
||||
"hidden": true
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
export const Default = () => <PageStory />;
|
24
stories/login/pages/Terms.stories.tsx
Normal file
24
stories/login/pages/Terms.stories.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import React from "react";
|
||||
import type { ComponentMeta } from "@storybook/react";
|
||||
import { createPageStory } from "../createPageStory";
|
||||
|
||||
const pageId = "terms.ftl";
|
||||
|
||||
const { PageStory } = createPageStory({ pageId });
|
||||
|
||||
const meta: ComponentMeta<any> = {
|
||||
title: `login/${pageId}`,
|
||||
component: PageStory,
|
||||
parameters: {
|
||||
viewMode: "story",
|
||||
previewTabs: {
|
||||
"storybook/docs/panel": {
|
||||
"hidden": true
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
export const Default = () => <PageStory />;
|
24
stories/login/pages/UpdateEmail.stories.tsx
Normal file
24
stories/login/pages/UpdateEmail.stories.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import React from "react";
|
||||
import type { ComponentMeta } from "@storybook/react";
|
||||
import { createPageStory } from "../createPageStory";
|
||||
|
||||
const pageId = "update-email.ftl";
|
||||
|
||||
const { PageStory } = createPageStory({ pageId });
|
||||
|
||||
const meta: ComponentMeta<any> = {
|
||||
title: `login/${pageId}`,
|
||||
component: PageStory,
|
||||
parameters: {
|
||||
viewMode: "story",
|
||||
previewTabs: {
|
||||
"storybook/docs/panel": {
|
||||
"hidden": true
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
export const Default = () => <PageStory />;
|
24
stories/login/pages/UpdateUserProfile.stories.tsx
Normal file
24
stories/login/pages/UpdateUserProfile.stories.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import React from "react";
|
||||
import type { ComponentMeta } from "@storybook/react";
|
||||
import { createPageStory } from "../createPageStory";
|
||||
|
||||
const pageId = "update-user-profile.ftl";
|
||||
|
||||
const { PageStory } = createPageStory({ pageId });
|
||||
|
||||
const meta: ComponentMeta<any> = {
|
||||
title: `login/${pageId}`,
|
||||
component: PageStory,
|
||||
parameters: {
|
||||
viewMode: "story",
|
||||
previewTabs: {
|
||||
"storybook/docs/panel": {
|
||||
"hidden": true
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
export const Default = () => <PageStory />;
|
24
stories/login/pages/WebauthnAuthenticate.stories.tsx
Normal file
24
stories/login/pages/WebauthnAuthenticate.stories.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import React from "react";
|
||||
import type { ComponentMeta } from "@storybook/react";
|
||||
import { createPageStory } from "../createPageStory";
|
||||
|
||||
const pageId = "webauthn-authenticate.ftl";
|
||||
|
||||
const { PageStory } = createPageStory({ pageId });
|
||||
|
||||
const meta: ComponentMeta<any> = {
|
||||
title: `login/${pageId}`,
|
||||
component: PageStory,
|
||||
parameters: {
|
||||
viewMode: "story",
|
||||
previewTabs: {
|
||||
"storybook/docs/panel": {
|
||||
"hidden": true
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
export const Default = () => <PageStory />;
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user