Compare commits
52 Commits
Author | SHA1 | Date | |
---|---|---|---|
6603ac4389 | |||
cca6f952ee | |||
df94a6322d | |||
73e7f64860 | |||
e17e1650d5 | |||
3ecb63d500 | |||
41ee7e90ef | |||
c70bba727e | |||
747248454d | |||
59386241b4 | |||
c70b9b0dd1 | |||
2ee00ed919 | |||
cbfc271da5 | |||
d45b492837 | |||
ed54c145b7 | |||
64ed9a6044 | |||
75267abd91 | |||
ba9a3992b7 | |||
a74c32ed6d | |||
c5f9812acc | |||
bb0d6853e5 | |||
8c9fe168d8 | |||
6c874c01b7 | |||
5bc84b621c | |||
dd421eedf5 | |||
570d8a73cc | |||
a95df42843 | |||
4ecbb30a1b | |||
96b40b9c49 | |||
c32eebdd46 | |||
5b17287555 | |||
fb01257c8b | |||
53470f8788 | |||
89b86936f6 | |||
d3a07edfcb | |||
98a3d6564e | |||
50a20c68ed | |||
3aad681538 | |||
92fb3b7529 | |||
1572f1137a | |||
b5075dd1eb | |||
9119caa843 | |||
f5c5a79064 | |||
357d804124 | |||
d59cb3b470 | |||
8ac292bd97 | |||
c6cab82546 | |||
04bf3692e4 | |||
6602aa0ee4 | |||
c8e099dedb | |||
9ede0800f1 | |||
fbdae316c7 |
58
CHANGELOG.md
58
CHANGELOG.md
@ -1,3 +1,61 @@
|
||||
### **4.2.7** (2021-11-09)
|
||||
|
||||
- Fix useFormValidationSlice
|
||||
|
||||
### **4.2.6** (2021-11-08)
|
||||
|
||||
- Fix deepClone so we can overwrite with undefined in when we mock kcContext
|
||||
|
||||
### **4.2.5** (2021-11-07)
|
||||
|
||||
- Better debugging experience with user profile
|
||||
|
||||
### **4.2.4** (2021-11-01)
|
||||
|
||||
- Better autoComplete typings
|
||||
|
||||
### **4.2.3** (2021-11-01)
|
||||
|
||||
- Make it more easy to understand that error in the log are expected
|
||||
|
||||
### **4.2.2** (2021-10-27)
|
||||
|
||||
- Replace 'path' by 'browserify-path' #47
|
||||
|
||||
### **4.2.1** (2021-10-26)
|
||||
|
||||
- useFormValidationSlice: update when params have changed
|
||||
- Explains that the password can't be validated
|
||||
|
||||
## **4.2.0** (2021-10-26)
|
||||
|
||||
- Export types definitions for Attribue and Validator
|
||||
|
||||
## **4.1.0** (2021-10-26)
|
||||
|
||||
- Document what's new in v4
|
||||
|
||||
# **4.0.0** (2021-10-26)
|
||||
|
||||
- fix RegisterUserProfile password confirmation field
|
||||
- Much better support for frontend field validation
|
||||
- Fix css injection order
|
||||
- Makes the download output predictable. This fixes the case where GitHub redirects and wget was trying to download a filename called "15.0.2", and then unzip wouldn't pick it up.
|
||||
Changes wget to curl because curl is awesome. -L is to follow the GitHub redirects.
|
||||
- Remove duplicates
|
||||
|
||||
### **3.0.2** (2021-10-18)
|
||||
|
||||
- Scan deeper to retreive user attribute
|
||||
|
||||
### **3.0.1** (2021-10-17)
|
||||
|
||||
- Add client.description in type kcContext type def
|
||||
|
||||
# **3.0.0** (2021-10-16)
|
||||
|
||||
|
||||
|
||||
### **2.5.3** (2021-10-16)
|
||||
|
||||
|
||||
|
97
README.md
97
README.md
@ -20,24 +20,10 @@
|
||||
<img src="https://user-images.githubusercontent.com/6702424/110260457-a1c3d380-7fac-11eb-853a-80459b65626b.png">
|
||||
</p>
|
||||
|
||||
**NEW in v2.5**
|
||||
**NEW in v4**
|
||||
|
||||
- User Profile ([`register-user-profile.ftl`](https://github.com/InseeFrLab/keycloakify/blob/main/src/lib/components/RegisterUserProfile.tsx))
|
||||
is now supported! 🎉
|
||||
It enables to [define, from the admin console](https://user-images.githubusercontent.com/6702424/136872461-1f5b64ef-d2ef-4c6b-bb8d-07d4729552b3.png),
|
||||
what information you want to collect on your users in the register page and to validate inputs
|
||||
[**on the frontend**, in realtime](https://github.com/InseeFrLab/keycloakify/blob/6dca6a93d8cfe634ee4d8574ad0c091641220092/src/lib/getKcContext/KcContextBase.ts#L225-L261)!
|
||||
NOTE: User profile is only available in Keycloak 15 and it's a beta feature that
|
||||
[needs to be enabled when launching keycloak](https://github.com/InseeFrLab/keycloakify/blob/59f106bf9e210b63b190826da2bf5f75fc8b7644/src/bin/build-keycloak-theme/build-keycloak-theme.ts#L116-L117) and [enabled in the console](https://user-images.githubusercontent.com/6702424/136874428-b071d614-c7f7-440d-9b2e-670faadc0871.png).
|
||||
- Feature [Use advanced message](https://github.com/InseeFrLab/keycloakify/blob/59f106bf9e210b63b190826da2bf5f75fc8b7644/src/lib/i18n/useKcMessage.tsx#L53-L66)
|
||||
and [`messagesPerFields`](https://github.com/InseeFrLab/keycloakify/blob/59f106bf9e210b63b190826da2bf5f75fc8b7644/src/lib/getKcContext/KcContextBase.ts#L70-L75) (implementation [here](https://github.com/InseeFrLab/keycloakify/blob/59f106bf9e210b63b190826da2bf5f75fc8b7644/src/bin/build-keycloak-theme/generateFtl/common.ftl#L130-L189))
|
||||
- Test container now uses Keycloak version `15.0.2`.
|
||||
|
||||
**NEW in v2**
|
||||
|
||||
- It's now possible to implement custom `.ftl` pages.
|
||||
- Support for Keycloak plugins that introduce non standard ftl values.
|
||||
(Like for example [this plugin](https://github.com/micedre/keycloak-mail-whitelisting) that define `authorizedMailDomains` in `register.ftl`).
|
||||
- Out of the box [frontend form validation](#user-profile-and-frontend-form-validation) 🥳
|
||||
- Improvements (and breaking changes in `import { useKcMessage } from "keycloakify"`.
|
||||
|
||||
# Motivations
|
||||
|
||||
@ -79,6 +65,7 @@ If you already have a Keycloak custom theme, it can be easily ported to Keycloak
|
||||
- [Advanced pages configuration](#advanced-pages-configuration)
|
||||
- [Hot reload](#hot-reload)
|
||||
- [Enable loading in a blink of an eye of login pages ⚡ (--external-assets)](#enable-loading-in-a-blink-of-an-eye-of-login-pages----external-assets)
|
||||
- [User profile and frontend form validation](#user-profile-and-frontend-form-validation)
|
||||
- [Support for Terms and conditions](#support-for-terms-and-conditions)
|
||||
- [Some pages still have the default theme. Why?](#some-pages-still-have-the-default-theme-why)
|
||||
- [GitHub Actions](#github-actions)
|
||||
@ -92,6 +79,11 @@ If you already have a Keycloak custom theme, it can be easily ported to Keycloak
|
||||
- [About the errors related to `objectToJson` in Keycloak logs.](#about-the-errors-related-to-objecttojson-in-keycloak-logs)
|
||||
- [Adding custom message (to `i18n/useKcMessage.tsx`)](#adding-custom-message-to-i18nusekcmessagetsx)
|
||||
- [Email domain whitelist](#email-domain-whitelist)
|
||||
- [Changelog highlights](#changelog-highlights)
|
||||
- [v4](#v4)
|
||||
- [v3](#v3)
|
||||
- [v2.5](#v25)
|
||||
- [v2](#v2)
|
||||
|
||||
# Requirements
|
||||
|
||||
@ -111,7 +103,7 @@ For more information see [this issue](https://github.com/InseeFrLab/keycloakify/
|
||||
|
||||
**All this is defaults with [`create-react-app`](https://create-react-app.dev)** (tested with 4.0.3)
|
||||
|
||||
- `mvn` ([Maven](https://maven.apache.org/)), `rm`, `mkdir`, `wget`, `unzip` are assumed to be available.
|
||||
- `mvn` ([Maven](https://maven.apache.org/)), `rm`, `mkdir`, `curl`, `unzip` are assumed to be available.
|
||||
- `docker` must be up and running when running `yarn keycloak`.
|
||||
|
||||
On Windows you'll have to use [WSL](https://docs.microsoft.com/en-us/windows/wsl/install-win10).
|
||||
@ -131,7 +123,7 @@ separate module. Checkout [ts_ci](https://github.com/garronej/ts_ci), it can hel
|
||||
## Setting up the build tool
|
||||
|
||||
```bash
|
||||
yarn add keycloakify
|
||||
yarn add keycloakify @emotion/react tss-react powerhooks
|
||||
```
|
||||
|
||||
[`package.json`](https://github.com/garronej/keycloakify-demo-app/blob/main/package.json)
|
||||
@ -307,6 +299,32 @@ performance boost if you jump through those hoops:
|
||||
|
||||
Checkout a complete setup [here](https://github.com/garronej/keycloakify-demo-app#about-keycloakify)
|
||||
|
||||
# User profile and frontend form validation
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/InseeFrLab/keycloakify/releases/download/v0.0.1/keycloakify_fontend_validation.mp4">
|
||||
<img src="https://user-images.githubusercontent.com/6702424/138880146-6fef3280-c4a5-46d2-bbb3-8b9598c057a5.gif">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
User Profile is a Keycloak feature that enables to
|
||||
[define, from the admin console](https://user-images.githubusercontent.com/6702424/136872461-1f5b64ef-d2ef-4c6b-bb8d-07d4729552b3.png),
|
||||
what information you want to collect on your users in the register page and to validate inputs
|
||||
[**on the frontend**, in realtime](https://github.com/InseeFrLab/keycloakify/blob/6dca6a93d8cfe634ee4d8574ad0c091641220092/src/lib/getKcContext/KcContextBase.ts#L225-L261)!
|
||||
|
||||
NOTE: User profile is only available in Keycloak 15 and it's a beta feature that
|
||||
[needs to be enabled when launching keycloak](https://github.com/InseeFrLab/keycloakify/blob/59f106bf9e210b63b190826da2bf5f75fc8b7644/src/bin/build-keycloak-theme/build-keycloak-theme.ts#L116-L117)
|
||||
and [enabled in the console](https://user-images.githubusercontent.com/6702424/136874428-b071d614-c7f7-440d-9b2e-670faadc0871.png).
|
||||
|
||||
Keycloakify, in [`register-user-profile.ftl`](https://github.com/InseeFrLab/keycloakify/blob/main/src/lib/components/RegisterUserProfile.tsx),
|
||||
provides frontend validation out of the box.
|
||||
|
||||
For implementing your own `register-user-profile.ftl` page, you can use [`import { useFormValidationSlice } from "keycloakify";`](https://github.com/InseeFrLab/keycloakify/blob/main/src/lib/useFormValidationSlice.tsx).
|
||||
Find usage example [`here`](https://github.com/InseeFrLab/keycloakify/blob/d3a07edfcb3739e30032dc96fc2a55944dfc3387/src/lib/components/RegisterUserProfile.tsx#L79-L112).
|
||||
|
||||
As for right now [it's not possible to define a pattern for the password](https://keycloak.discourse.group/t/make-password-policies-available-to-freemarker/11632)
|
||||
from the admin console. You can however pass validators for it to the `useFormValidationSlice` function.
|
||||
|
||||
# Support for Terms and conditions
|
||||
|
||||
[Many organizations have a requirement that when a new user logs in for the first time, they need to agree to the terms and conditions of the website.](https://www.keycloak.org/docs/4.8/server_admin/#terms-and-conditions).
|
||||
@ -426,18 +444,20 @@ The logs of your keycloak server will always show this kind of errors every time
|
||||
|
||||
```log
|
||||
FTL stack trace ("~" means nesting-related):
|
||||
- Failed at: #local value = object[key] [in template "login.ftl" in macro "objectToJson" at line 70, column 21]
|
||||
- Reached through: @compress [in template "login.ftl" in macro "objectToJson" at line 36, column 5]
|
||||
- Reached through: @objectToJson object=value depth=(dep... [in template "login.ftl" in macro "objectToJson" at line 81, column 27]
|
||||
- Reached through: @compress [in template "login.ftl" in macro "objectToJson" at line 36, column 5]
|
||||
- Reached through: @objectToJson object=(.data_model) de... [in template "login.ftl" at line 163, column 43]
|
||||
- Failed at: #local value = object[key] [in template "login.ftl" in macro "objectToJson_please_ignore_errors" at line 70, column 21]
|
||||
- Reached through: @compress [in template "login.ftl" in macro "objectToJson_please_ignore_errors" at line 36, column 5]
|
||||
- Reached through: @objectToJson_please_ignore_errors object=value depth=(dep... [in template "login.ftl" in macro "objectToJson_please_ignore_errors" at line 81, column 27]
|
||||
- Reached through: @compress [in template "login.ftl" in macro "objectToJson_please_ignore_errors" at line 36, column 5]
|
||||
- Reached through: @objectToJson_please_ignore_errors object=(.data_model) de... [in template "login.ftl" at line 163, column 43]
|
||||
```
|
||||
|
||||
Theses are expected and can be safely ignored.
|
||||
Theses are expected to show up in the log.
|
||||
Unfortunately, there is nothing I know of that can be done to avoid them or even mute them.
|
||||
They can be, however, safely ignored.
|
||||
|
||||
To [converts the `.ftl` values into a JavaScript object](https://github.com/InseeFrLab/keycloakify/blob/main/src/bin/build-keycloak-theme/generateFtl/common.ftl)
|
||||
without making assumptions on the `.data_model` we have to do things that throws.
|
||||
It's all-right though because every statement that can fail is inside an `<#attempt><#recorver>` block but it results in errors being printed to the logs.
|
||||
It's all-right because every statement that can fail is inside an `<#attempt><#recorver>` block but it results in errors being printed to the logs.
|
||||
|
||||
# Adding custom message (to `i18n/useKcMessage.tsx`)
|
||||
|
||||
@ -449,3 +469,28 @@ This approach is a bit hacky as it doesn't provide type safety but it works.
|
||||
|
||||
If you want to restrict the emails domain that can register, you can use [this plugin](https://github.com/micedre/keycloak-mail-whitelisting)
|
||||
and `kcRegisterContext["authorizedMailDomains"]` to validate on.
|
||||
|
||||
# Changelog highlights
|
||||
|
||||
## v4
|
||||
|
||||
- Out of the box [frontend form validation](#user-profile-and-frontend-form-validation) 🥳
|
||||
- Improvements (and breaking changes in `import { useKcMessage } from "keycloakify"`.
|
||||
|
||||
## v3
|
||||
|
||||
No breaking changes except that `@emotion/react`, [`tss-react`](https://www.npmjs.com/package/tss-react) and [`powerhooks`](https://www.npmjs.com/package/powerhooks) are now `peerDependencies` instead of being just dependencies.
|
||||
It's important to avoid problem when using `keycloakify` alongside [`mui`](https://mui.com) and
|
||||
[when passing params from the app to the login page](https://github.com/InseeFrLab/keycloakify#implement-context-persistence-optional).
|
||||
|
||||
## v2.5
|
||||
|
||||
- Feature [Use advanced message](https://github.com/InseeFrLab/keycloakify/blob/59f106bf9e210b63b190826da2bf5f75fc8b7644/src/lib/i18n/useKcMessage.tsx#L53-L66)
|
||||
and [`messagesPerFields`](https://github.com/InseeFrLab/keycloakify/blob/59f106bf9e210b63b190826da2bf5f75fc8b7644/src/lib/getKcContext/KcContextBase.ts#L70-L75) (implementation [here](https://github.com/InseeFrLab/keycloakify/blob/59f106bf9e210b63b190826da2bf5f75fc8b7644/src/bin/build-keycloak-theme/generateFtl/common.ftl#L130-L189))
|
||||
- Test container now uses Keycloak version `15.0.2`.
|
||||
|
||||
## v2
|
||||
|
||||
- It's now possible to implement custom `.ftl` pages.
|
||||
- Support for Keycloak plugins that introduce non standard ftl values.
|
||||
(Like for example [this plugin](https://github.com/micedre/keycloak-mail-whitelisting) that define `authorizedMailDomains` in `register.ftl`).
|
||||
|
26
package.json
26
package.json
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "keycloakify",
|
||||
"version": "2.5.3",
|
||||
"version": "4.2.7",
|
||||
"description": "Keycloak theme generator for Reacts app",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@ -54,28 +54,34 @@
|
||||
"register"
|
||||
],
|
||||
"homepage": "https://github.com/garronej/keycloakify",
|
||||
"peerDependencies": {
|
||||
"@emotion/react": "^11.4.1",
|
||||
"powerhooks": "^0.10.0",
|
||||
"react": "^16.8.0 || ^17.0.0",
|
||||
"tss-react": "^1.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@emotion/react": "^11.4.1",
|
||||
"@types/node": "^10.0.0",
|
||||
"@types/react": "^17.0.0",
|
||||
"copyfiles": "^2.4.1",
|
||||
"husky": "^4.3.8",
|
||||
"lint-staged": "^11.0.0",
|
||||
"powerhooks": "^0.11.0",
|
||||
"prettier": "^2.3.0",
|
||||
"properties-parser": "^0.3.1",
|
||||
"react": "^17.0.1",
|
||||
"rimraf": "^3.0.2",
|
||||
"typescript": "^4.2.3",
|
||||
"husky": "^4.3.8",
|
||||
"lint-staged": "^11.0.0",
|
||||
"prettier": "^2.3.0"
|
||||
"tss-react": "^1.1.0",
|
||||
"typescript": "^4.2.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.4.1",
|
||||
"cheerio": "^1.0.0-rc.5",
|
||||
"evt": "2.0.0-beta.38",
|
||||
"minimal-polyfills": "^2.2.1",
|
||||
"path": "^0.12.7",
|
||||
"powerhooks": "^0.9.4",
|
||||
"path-browserify": "^1.0.1",
|
||||
"react-markdown": "^5.0.3",
|
||||
"scripting-tools": "^0.19.13",
|
||||
"tsafe": "^0.8.1",
|
||||
"tss-react": "^1.1.0"
|
||||
"tsafe": "^0.8.1"
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
<script>const _=
|
||||
<#macro objectToJson object depth>
|
||||
<#macro objectToJson_please_ignore_errors object depth>
|
||||
<@compress>
|
||||
|
||||
<#local isHash = false>
|
||||
@ -40,12 +40,12 @@
|
||||
<#continue>
|
||||
</#attempt>
|
||||
|
||||
<#if depth gt 4>
|
||||
<#if depth gt 7>
|
||||
/* Avoid calling recustively too many times depth: ${depth}, key: ${key} */
|
||||
<#continue>
|
||||
</#if>
|
||||
|
||||
"${key}": <@objectToJson object=value depth=depth+1/>,
|
||||
"${key}": <@objectToJson_please_ignore_errors object=value depth=depth+1/>,
|
||||
|
||||
</#list>
|
||||
|
||||
@ -102,7 +102,7 @@
|
||||
|
||||
<#list object as item>
|
||||
|
||||
<@objectToJson object=item depth=depth+1/>,
|
||||
<@objectToJson_please_ignore_errors object=item depth=depth+1/>,
|
||||
|
||||
</#list>
|
||||
|
||||
@ -194,7 +194,7 @@
|
||||
Object.deepAssign(
|
||||
out,
|
||||
//Removing all the undefined
|
||||
JSON.parse(JSON.stringify(<@objectToJson object=.data_model depth=0 />))
|
||||
JSON.parse(JSON.stringify(<@objectToJson_please_ignore_errors object=.data_model depth=0 />))
|
||||
);
|
||||
|
||||
Object.deepAssign(
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { basename as pathBasename, join as pathJoin } from "path";
|
||||
import { execSync } from "child_process";
|
||||
import fs from "fs";
|
||||
import { transformCodebase } from "../tools/transformCodebase";
|
||||
import { transformCodebase } from "./transformCodebase";
|
||||
import { rm_rf, rm, rm_r } from "./rm";
|
||||
|
||||
/** assert url ends with .zip */
|
||||
@ -9,14 +9,15 @@ export function downloadAndUnzip(params: { url: string; destDirPath: string; pat
|
||||
const { url, destDirPath, pathOfDirToExtractInArchive } = params;
|
||||
|
||||
const tmpDirPath = pathJoin(destDirPath, "..", "tmp_xxKdOxnEdx");
|
||||
const zipFilePath = pathBasename(url);
|
||||
|
||||
rm_rf(tmpDirPath);
|
||||
|
||||
fs.mkdirSync(tmpDirPath, { "recursive": true });
|
||||
|
||||
execSync(`wget ${url}`, { "cwd": tmpDirPath });
|
||||
execSync(`curl -L ${url} -o ${zipFilePath}`, { "cwd": tmpDirPath });
|
||||
|
||||
execSync(`unzip ${pathBasename(url)}${pathOfDirToExtractInArchive === undefined ? "" : ` "${pathOfDirToExtractInArchive}/*"`}`, {
|
||||
execSync(`unzip ${zipFilePath}${pathOfDirToExtractInArchive === undefined ? "" : ` "${pathOfDirToExtractInArchive}/*"`}`, {
|
||||
"cwd": tmpDirPath,
|
||||
});
|
||||
|
||||
|
@ -20,16 +20,12 @@ export type KcTemplateClassKey =
|
||||
| "kcLocaleWrapperClass"
|
||||
| "kcContentWrapperClass"
|
||||
| "kcLabelWrapperClass"
|
||||
| "kcContentWrapperClass"
|
||||
| "kcLabelWrapperClass"
|
||||
| "kcFormGroupClass"
|
||||
| "kcResetFlowIcon"
|
||||
| "kcResetFlowIcon"
|
||||
| "kcFeedbackSuccessIcon"
|
||||
| "kcFeedbackWarningIcon"
|
||||
| "kcFeedbackErrorIcon"
|
||||
| "kcFeedbackInfoIcon"
|
||||
| "kcContentWrapperClass"
|
||||
| "kcFormSocialAccountContentClass"
|
||||
| "kcFormSocialAccountClass"
|
||||
| "kcSignUpClass"
|
||||
|
@ -3,7 +3,7 @@ import { Template } from "./Template";
|
||||
import type { KcProps } from "./KcProps";
|
||||
import type { KcContextBase } from "../getKcContext/KcContextBase";
|
||||
import { useKcMessage } from "../i18n/useKcMessage";
|
||||
import { appendHead } from "../tools/appendHead";
|
||||
import { headInsert } from "../tools/headInsert";
|
||||
import { join as pathJoin } from "path";
|
||||
import { useCssAndCx } from "tss-react";
|
||||
|
||||
@ -17,7 +17,7 @@ export const LoginOtp = memo(({ kcContext, ...props }: { kcContext: KcContextBas
|
||||
useEffect(() => {
|
||||
let isCleanedUp = false;
|
||||
|
||||
appendHead({
|
||||
headInsert({
|
||||
"type": "javascript",
|
||||
"src": pathJoin(kcContext.url.resourcesCommonPath, "node_modules/jquery/dist/jquery.min.js"),
|
||||
}).then(() => {
|
||||
|
@ -1,17 +1,29 @@
|
||||
import { memo, Fragment } from "react";
|
||||
import { useMemo, memo, useEffect, useState, Fragment } from "react";
|
||||
import { Template } from "./Template";
|
||||
import type { KcProps } from "./KcProps";
|
||||
import type { KcContextBase } from "../getKcContext/KcContextBase";
|
||||
import type { KcContextBase, Attribute } from "../getKcContext/KcContextBase";
|
||||
import { useKcMessage } from "../i18n/useKcMessage";
|
||||
import { useCssAndCx } from "tss-react";
|
||||
import type { ReactComponent } from "../tools/ReactComponent";
|
||||
import { useCallbackFactory } from "powerhooks/useCallbackFactory";
|
||||
import { useFormValidationSlice } from "../useFormValidationSlice";
|
||||
|
||||
export const RegisterUserProfile = memo(({ kcContext, ...props }: { kcContext: KcContextBase.RegisterUserProfile } & KcProps) => {
|
||||
const { url, messagesPerField, realm, passwordRequired, recaptchaRequired, recaptchaSiteKey } = kcContext;
|
||||
export const RegisterUserProfile = memo(({ kcContext, ...props_ }: { kcContext: KcContextBase.RegisterUserProfile } & KcProps) => {
|
||||
const { url, messagesPerField, recaptchaRequired, recaptchaSiteKey } = kcContext;
|
||||
|
||||
const { msg, msgStr } = useKcMessage();
|
||||
|
||||
const { cx } = useCssAndCx();
|
||||
const { cx, css } = useCssAndCx();
|
||||
|
||||
const props = useMemo(
|
||||
() => ({
|
||||
...props_,
|
||||
"kcFormGroupClass": cx(props_.kcFormGroupClass, css({ "marginBottom": 20 })),
|
||||
}),
|
||||
[cx, css],
|
||||
);
|
||||
|
||||
const [isFomSubmittable, setIsFomSubmittable] = useState(false);
|
||||
|
||||
return (
|
||||
<Template
|
||||
@ -22,71 +34,7 @@ export const RegisterUserProfile = memo(({ kcContext, ...props }: { kcContext: K
|
||||
headerNode={msg("registerTitle")}
|
||||
formNode={
|
||||
<form id="kc-register-form" className={cx(props.kcFormClass)} action={url.registrationAction} method="post">
|
||||
<UserProfileFormFields
|
||||
kcContext={kcContext}
|
||||
{...props}
|
||||
AfterField={({ attribute }) =>
|
||||
/*render password fields just under the username or email (if used as username)*/
|
||||
(passwordRequired &&
|
||||
(attribute.name == "username" || (attribute.name == "email" && realm.registrationEmailAsUsername)) && (
|
||||
<>
|
||||
<div className={cx(props.kcFormGroupClass)}>
|
||||
<div className={cx(props.kcLabelWrapperClass)}>
|
||||
<label htmlFor="password" className={cx(props.kcLabelClass)}>
|
||||
{msg("password")}
|
||||
</label>{" "}
|
||||
*
|
||||
</div>
|
||||
<div className={cx(props.kcInputWrapperClass)}>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
className={cx(props.kcInputClass)}
|
||||
name="password"
|
||||
autoComplete="new-password"
|
||||
aria-invalid={
|
||||
messagesPerField.existsError("password") || messagesPerField.existsError("password-confirm")
|
||||
}
|
||||
/>
|
||||
{messagesPerField.existsError("password") && (
|
||||
<span id="input-error-password" className={cx(props.kcInputErrorMessageClass)} aria-live="polite">
|
||||
{messagesPerField.get("password")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={cx(props.kcFormGroupClass)}>
|
||||
<div className={cx(props.kcLabelWrapperClass)}>
|
||||
<label htmlFor="password-confirm" className={cx(props.kcLabelClass)}>
|
||||
{msg("passwordConfirm")}
|
||||
</label>{" "}
|
||||
*
|
||||
</div>
|
||||
<div className={cx(props.kcInputWrapperClass)}>
|
||||
<input
|
||||
type="password"
|
||||
id="password-confirm"
|
||||
className={cx(props.kcInputClass)}
|
||||
name="password-confirm"
|
||||
autoComplete="new-password"
|
||||
aria-invalid={messagesPerField.existsError("password-confirm")}
|
||||
/>
|
||||
{messagesPerField.existsError("password-confirm") && (
|
||||
<span
|
||||
id="input-error-password-confirm"
|
||||
className={cx(props.kcInputErrorMessageClass)}
|
||||
aria-live="polite"
|
||||
>
|
||||
{messagesPerField.get("password-confirm")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)) ||
|
||||
null
|
||||
}
|
||||
/>
|
||||
<UserProfileFormFields kcContext={kcContext} onIsFormSubmittableValueChange={setIsFomSubmittable} {...props} />
|
||||
{recaptchaRequired && (
|
||||
<div className="form-group">
|
||||
<div className={cx(props.kcInputWrapperClass)}>
|
||||
@ -108,6 +56,7 @@ export const RegisterUserProfile = memo(({ kcContext, ...props }: { kcContext: K
|
||||
className={cx(props.kcButtonClass, props.kcButtonPrimaryClass, props.kcButtonBlockClass, props.kcButtonLargeClass)}
|
||||
type="submit"
|
||||
value={msgStr("doRegister")}
|
||||
disabled={!isFomSubmittable}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -117,85 +66,128 @@ export const RegisterUserProfile = memo(({ kcContext, ...props }: { kcContext: K
|
||||
);
|
||||
});
|
||||
|
||||
const UserProfileFormFields = memo(
|
||||
({
|
||||
type UserProfileFormFieldsProps = { kcContext: KcContextBase.RegisterUserProfile } & KcProps &
|
||||
Partial<Record<"BeforeField" | "AfterField", ReactComponent<{ attribute: Attribute }>>> & {
|
||||
onIsFormSubmittableValueChange: (isFormSubmittable: boolean) => void;
|
||||
};
|
||||
|
||||
const UserProfileFormFields = memo(({ kcContext, onIsFormSubmittableValueChange, ...props }: UserProfileFormFieldsProps) => {
|
||||
const { cx, css } = useCssAndCx();
|
||||
|
||||
const { advancedMsg } = useKcMessage();
|
||||
|
||||
const {
|
||||
formValidationState: { fieldStateByAttributeName, isFormSubmittable },
|
||||
formValidationReducer,
|
||||
attributesWithPassword,
|
||||
} = useFormValidationSlice({
|
||||
kcContext,
|
||||
BeforeField = () => null,
|
||||
AfterField = () => null,
|
||||
...props
|
||||
}: { kcContext: KcContextBase.RegisterUserProfile } & KcProps &
|
||||
Partial<
|
||||
Record<
|
||||
"BeforeField" | "AfterField",
|
||||
ReactComponent<{
|
||||
attribute: KcContextBase.RegisterUserProfile["profile"]["attributes"][number];
|
||||
}>
|
||||
>
|
||||
>) => {
|
||||
const { messagesPerField } = kcContext;
|
||||
});
|
||||
|
||||
const { cx } = useCssAndCx();
|
||||
useEffect(() => {
|
||||
onIsFormSubmittableValueChange(isFormSubmittable);
|
||||
}, [isFormSubmittable]);
|
||||
|
||||
const { advancedMsg } = useKcMessage();
|
||||
const onChangeFactory = useCallbackFactory(
|
||||
(
|
||||
[name]: [string],
|
||||
[
|
||||
{
|
||||
target: { value },
|
||||
},
|
||||
]: [React.ChangeEvent<HTMLInputElement>],
|
||||
) =>
|
||||
formValidationReducer({
|
||||
"action": "update value",
|
||||
name,
|
||||
"newValue": value,
|
||||
}),
|
||||
);
|
||||
|
||||
let currentGroup = "";
|
||||
const onBlurFactory = useCallbackFactory(([name]: [string]) =>
|
||||
formValidationReducer({
|
||||
"action": "focus lost",
|
||||
name,
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{kcContext.profile.attributes
|
||||
.map(attribute => [attribute, attribute])
|
||||
.map(([attribute, { group = "", groupDisplayHeader = "", groupDisplayDescription = "" }], i) => (
|
||||
<Fragment key={i}>
|
||||
{group !== currentGroup && (currentGroup = group) !== "" && (
|
||||
<div className={cx(props.kcFormGroupClass)}>
|
||||
<div className={cx(props.kcContentWrapperClass)}>
|
||||
<label id={`header-${group}`} className={cx(props.kcFormGroupHeader)}>
|
||||
{(groupDisplayHeader !== "" && advancedMsg(groupDisplayHeader)) || currentGroup}
|
||||
let currentGroup = "";
|
||||
|
||||
return (
|
||||
<>
|
||||
{attributesWithPassword.map((attribute, i) => {
|
||||
const { group = "", groupDisplayHeader = "", groupDisplayDescription = "" } = attribute;
|
||||
|
||||
const { value, displayableErrors } = fieldStateByAttributeName[attribute.name];
|
||||
|
||||
const formGroupClassName = cx(props.kcFormGroupClass, displayableErrors.length !== 0 && props.kcFormGroupErrorClass);
|
||||
|
||||
return (
|
||||
<Fragment key={i}>
|
||||
{group !== currentGroup && (currentGroup = group) !== "" && (
|
||||
<div className={formGroupClassName}>
|
||||
<div className={cx(props.kcContentWrapperClass)}>
|
||||
<label id={`header-${group}`} className={cx(props.kcFormGroupHeader)}>
|
||||
{advancedMsg(groupDisplayHeader) || currentGroup}
|
||||
</label>
|
||||
</div>
|
||||
{groupDisplayDescription !== "" && (
|
||||
<div className={cx(props.kcLabelWrapperClass)}>
|
||||
<label id={`description-${group}`} className={`${cx(props.kcLabelClass)}`}>
|
||||
{advancedMsg(groupDisplayDescription)}
|
||||
</label>
|
||||
</div>
|
||||
{groupDisplayDescription !== "" && (
|
||||
<div className={cx(props.kcLabelWrapperClass)}>
|
||||
<label id={`description-${group}`} className={`${cx(props.kcLabelClass)}`}>
|
||||
{advancedMsg(groupDisplayDescription) ?? ""}
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<BeforeField attribute={attribute} />
|
||||
<div className={cx(props.kcFormGroupClass)}>
|
||||
<div className={cx(props.kcLabelWrapperClass)}>
|
||||
<label htmlFor={attribute.name} className={cx(props.kcLabelClass)}>
|
||||
{advancedMsg(attribute.displayName ?? "")}
|
||||
</label>
|
||||
{attribute.required && <>*</>}
|
||||
</div>
|
||||
<div className={cx(props.kcInputWrapperClass)}>
|
||||
<input
|
||||
type="text"
|
||||
id={attribute.name}
|
||||
name={attribute.name}
|
||||
defaultValue={attribute.value ?? ""}
|
||||
className={cx(props.kcInputClass)}
|
||||
aria-invalid={messagesPerField.existsError(attribute.name)}
|
||||
disabled={attribute.readOnly}
|
||||
{...(attribute.autocomplete === undefined
|
||||
? {}
|
||||
: {
|
||||
"autoComplete": attribute.autocomplete,
|
||||
})}
|
||||
/>
|
||||
{kcContext.messagesPerField.existsError(attribute.name) && (
|
||||
<span id={`input-error-${attribute.name}`} className={cx(props.kcInputErrorMessageClass)} aria-live="polite">
|
||||
{messagesPerField.get(attribute.name)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<AfterField attribute={attribute} />
|
||||
</Fragment>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
)}
|
||||
<div className={formGroupClassName}>
|
||||
<div className={cx(props.kcLabelWrapperClass)}>
|
||||
<label htmlFor={attribute.name} className={cx(props.kcLabelClass)}>
|
||||
{advancedMsg(attribute.displayName ?? "")}
|
||||
</label>
|
||||
{attribute.required && <>*</>}
|
||||
</div>
|
||||
<div className={cx(props.kcInputWrapperClass)}>
|
||||
<input
|
||||
type={(() => {
|
||||
switch (attribute.name) {
|
||||
case "password-confirm":
|
||||
case "password":
|
||||
return "password";
|
||||
default:
|
||||
return "text";
|
||||
}
|
||||
})()}
|
||||
id={attribute.name}
|
||||
name={attribute.name}
|
||||
value={value}
|
||||
onChange={onChangeFactory(attribute.name)}
|
||||
className={cx(props.kcInputClass)}
|
||||
aria-invalid={displayableErrors.length !== 0}
|
||||
disabled={attribute.readOnly}
|
||||
autoComplete={attribute.autocomplete}
|
||||
onBlur={onBlurFactory(attribute.name)}
|
||||
/>
|
||||
{displayableErrors.length !== 0 && (
|
||||
<span
|
||||
id={`input-error-${attribute.name}`}
|
||||
className={cx(
|
||||
props.kcInputErrorMessageClass,
|
||||
css({
|
||||
"position": displayableErrors.length === 1 ? "absolute" : undefined,
|
||||
"& > span": { "display": "block" },
|
||||
}),
|
||||
)}
|
||||
aria-live="polite"
|
||||
>
|
||||
{displayableErrors.map(({ errorMessage }) => errorMessage)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
@ -8,7 +8,7 @@ import type { KcLanguageTag } from "../i18n/KcLanguageTag";
|
||||
import { getBestMatchAmongKcLanguageTag } from "../i18n/KcLanguageTag";
|
||||
import { getKcLanguageTagLabel } from "../i18n/KcLanguageTag";
|
||||
import { useCallbackFactory } from "powerhooks/useCallbackFactory";
|
||||
import { appendHead } from "../tools/appendHead";
|
||||
import { headInsert } from "../tools/headInsert";
|
||||
import { join as pathJoin } from "path";
|
||||
import { useConstCallback } from "powerhooks/useConstCallback";
|
||||
import type { KcTemplateProps } from "./KcProps";
|
||||
@ -92,12 +92,15 @@ export const Template = memo((props: TemplateProps) => {
|
||||
[
|
||||
...toArr(props.stylesCommon).map(relativePath => pathJoin(url.resourcesCommonPath, relativePath)),
|
||||
...toArr(props.styles).map(relativePath => pathJoin(url.resourcesPath, relativePath)),
|
||||
].map(href =>
|
||||
appendHead({
|
||||
"type": "css",
|
||||
href,
|
||||
}),
|
||||
),
|
||||
]
|
||||
.reverse()
|
||||
.map(href =>
|
||||
headInsert({
|
||||
"type": "css",
|
||||
href,
|
||||
"position": "prepend",
|
||||
}),
|
||||
),
|
||||
).then(() => {
|
||||
if (isUnmounted) {
|
||||
return;
|
||||
@ -107,7 +110,7 @@ export const Template = memo((props: TemplateProps) => {
|
||||
});
|
||||
|
||||
toArr(props.scripts).forEach(relativePath =>
|
||||
appendHead({
|
||||
headInsert({
|
||||
"type": "javascript",
|
||||
"src": pathJoin(url.resourcesPath, relativePath),
|
||||
}),
|
||||
|
@ -65,6 +65,7 @@ export declare namespace KcContextBase {
|
||||
client: {
|
||||
clientId: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
};
|
||||
isAppInitiatedAction: boolean;
|
||||
messagesPerField: {
|
||||
@ -216,10 +217,64 @@ export type Attribute = {
|
||||
groupDisplayHeader?: string;
|
||||
groupDisplayDescription?: string;
|
||||
readOnly: boolean;
|
||||
autocomplete?: string;
|
||||
validators: Validators;
|
||||
annotations: Record<string, string>;
|
||||
groupAnnotations: Record<string, string>;
|
||||
autocomplete?:
|
||||
| "on"
|
||||
| "off"
|
||||
| "name"
|
||||
| "honorific-prefix"
|
||||
| "given-name"
|
||||
| "additional-name"
|
||||
| "family-name"
|
||||
| "honorific-suffix"
|
||||
| "nickname"
|
||||
| "email"
|
||||
| "username"
|
||||
| "new-password"
|
||||
| "current-password"
|
||||
| "one-time-code"
|
||||
| "organization-title"
|
||||
| "organization"
|
||||
| "street-address"
|
||||
| "address-line1"
|
||||
| "address-line2"
|
||||
| "address-line3"
|
||||
| "address-level4"
|
||||
| "address-level3"
|
||||
| "address-level2"
|
||||
| "address-level1"
|
||||
| "country"
|
||||
| "country-name"
|
||||
| "postal-code"
|
||||
| "cc-name"
|
||||
| "cc-given-name"
|
||||
| "cc-additional-name"
|
||||
| "cc-family-name"
|
||||
| "cc-number"
|
||||
| "cc-exp"
|
||||
| "cc-exp-month"
|
||||
| "cc-exp-year"
|
||||
| "cc-csc"
|
||||
| "cc-type"
|
||||
| "transaction-currency"
|
||||
| "transaction-amount"
|
||||
| "language"
|
||||
| "bday"
|
||||
| "bday-day"
|
||||
| "bday-month"
|
||||
| "bday-year"
|
||||
| "sex"
|
||||
| "tel"
|
||||
| "tel-country-code"
|
||||
| "tel-national"
|
||||
| "tel-area-code"
|
||||
| "tel-local"
|
||||
| "tel-extension"
|
||||
| "impp"
|
||||
| "url"
|
||||
| "photo";
|
||||
};
|
||||
|
||||
export type Validators = Partial<{
|
||||
@ -242,6 +297,12 @@ export type Validators = Partial<{
|
||||
"person-name-prohibited-characters": Validators.DoIgnoreEmpty & Validators.ErrorMessage;
|
||||
uri: Validators.DoIgnoreEmpty;
|
||||
"username-prohibited-characters": Validators.DoIgnoreEmpty & Validators.ErrorMessage;
|
||||
/** Made up validator that only exists in Keycloakify */
|
||||
_compareToOther: Validators.DoIgnoreEmpty &
|
||||
Validators.ErrorMessage & {
|
||||
name: string;
|
||||
shouldBe: "equal" | "different";
|
||||
};
|
||||
}>;
|
||||
|
||||
export declare namespace Validators {
|
||||
@ -255,8 +316,8 @@ export declare namespace Validators {
|
||||
|
||||
export type Range = {
|
||||
/** "0", "1", "2"... yeah I know, don't tell me */
|
||||
min?: string;
|
||||
max?: string;
|
||||
min?: `${number}`;
|
||||
max?: `${number}`;
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -1,13 +1,12 @@
|
||||
import type { KcContextBase } from "./KcContextBase";
|
||||
import type { KcContextBase, Attribute } from "./KcContextBase";
|
||||
import { kcContextMocks, kcContextCommonMock } from "./kcContextMocks";
|
||||
import { ftlValuesGlobalName } from "../../bin/build-keycloak-theme/ftlValuesGlobalName";
|
||||
import type { AndByDiscriminatingKey } from "../tools/AndByDiscriminatingKey";
|
||||
import type { DeepPartial } from "../tools/DeepPartial";
|
||||
import { deepAssign } from "../tools/deepAssign";
|
||||
|
||||
export type ExtendsKcContextBase<KcContextExtended extends { pageId: string }> = [KcContextExtended] extends [never]
|
||||
? KcContextBase
|
||||
: AndByDiscriminatingKey<"pageId", KcContextExtended & KcContextBase.Common, KcContextBase>;
|
||||
import { id } from "tsafe/id";
|
||||
import { exclude } from "tsafe/exclude";
|
||||
import { assert } from "tsafe/assert";
|
||||
import type { ExtendsKcContextBase } from "./getKcContextFromWindow";
|
||||
import { getKcContextFromWindow } from "./getKcContextFromWindow";
|
||||
|
||||
export function getKcContext<KcContextExtended extends { pageId: string } = never>(params?: {
|
||||
mockPageId?: ExtendsKcContextBase<KcContextExtended>["pageId"];
|
||||
@ -44,12 +43,55 @@ export function getKcContext<KcContextExtended extends { pageId: string } = neve
|
||||
"target": kcContext,
|
||||
"source": partialKcContextCustomMock,
|
||||
});
|
||||
|
||||
if (partialKcContextCustomMock.pageId === "register-user-profile.ftl") {
|
||||
assert(kcContextDefaultMock?.pageId === "register-user-profile.ftl");
|
||||
|
||||
const { attributes } = kcContextDefaultMock.profile;
|
||||
|
||||
id<KcContextBase.RegisterUserProfile>(kcContext).profile.attributes = [];
|
||||
id<KcContextBase.RegisterUserProfile>(kcContext).profile.attributesByName = {};
|
||||
|
||||
const partialAttributes = [
|
||||
...((partialKcContextCustomMock as DeepPartial<KcContextBase.RegisterUserProfile>).profile?.attributes ?? []),
|
||||
].filter(exclude(undefined));
|
||||
|
||||
attributes.forEach(attribute => {
|
||||
const partialAttribute = partialAttributes.find(({ name }) => name === attribute.name);
|
||||
|
||||
const augmentedAttribute: Attribute = {} as any;
|
||||
|
||||
deepAssign({
|
||||
"target": augmentedAttribute,
|
||||
"source": attribute,
|
||||
});
|
||||
|
||||
if (partialAttribute !== undefined) {
|
||||
partialAttributes.splice(partialAttributes.indexOf(partialAttribute), 1);
|
||||
|
||||
deepAssign({
|
||||
"target": augmentedAttribute,
|
||||
"source": partialAttribute,
|
||||
});
|
||||
}
|
||||
|
||||
id<KcContextBase.RegisterUserProfile>(kcContext).profile.attributes.push(augmentedAttribute);
|
||||
id<KcContextBase.RegisterUserProfile>(kcContext).profile.attributesByName[augmentedAttribute.name] = augmentedAttribute;
|
||||
});
|
||||
|
||||
partialAttributes.forEach(partialAttribute => {
|
||||
const { name } = partialAttribute;
|
||||
|
||||
assert(name !== undefined, "If you define a mock attribute it must have at least a name");
|
||||
|
||||
id<KcContextBase.RegisterUserProfile>(kcContext).profile.attributes.push(partialAttribute as any);
|
||||
id<KcContextBase.RegisterUserProfile>(kcContext).profile.attributesByName[name] = partialAttribute as any;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { kcContext };
|
||||
}
|
||||
|
||||
return {
|
||||
"kcContext": typeof window === "undefined" ? undefined : (window as any)[ftlValuesGlobalName],
|
||||
};
|
||||
return { "kcContext": getKcContextFromWindow<KcContextExtended>() };
|
||||
}
|
||||
|
11
src/lib/getKcContext/getKcContextFromWindow.ts
Normal file
11
src/lib/getKcContext/getKcContextFromWindow.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import type { KcContextBase } from "./KcContextBase";
|
||||
import type { AndByDiscriminatingKey } from "../tools/AndByDiscriminatingKey";
|
||||
import { ftlValuesGlobalName } from "../../bin/build-keycloak-theme/ftlValuesGlobalName";
|
||||
|
||||
export type ExtendsKcContextBase<KcContextExtended extends { pageId: string }> = [KcContextExtended] extends [never]
|
||||
? KcContextBase
|
||||
: AndByDiscriminatingKey<"pageId", KcContextExtended & KcContextBase.Common, KcContextBase>;
|
||||
|
||||
export function getKcContextFromWindow<KcContextExtended extends { pageId: string } = never>(): ExtendsKcContextBase<KcContextExtended> | undefined {
|
||||
return typeof window === "undefined" ? undefined : (window as any)[ftlValuesGlobalName];
|
||||
}
|
@ -1,2 +1,3 @@
|
||||
export type { KcContextBase } from "./KcContextBase";
|
||||
export type { KcContextBase, Attribute, Validators } from "./KcContextBase";
|
||||
export type { ExtendsKcContextBase } from "./getKcContextFromWindow";
|
||||
export { getKcContext } from "./getKcContext";
|
||||
|
@ -210,6 +210,7 @@ export const kcContextMocks: KcContextBase[] = [
|
||||
"autocomplete": "username",
|
||||
"readOnly": false,
|
||||
"name": "username",
|
||||
"value": "xxxx",
|
||||
},
|
||||
{
|
||||
"validators": {
|
||||
@ -226,6 +227,10 @@ export const kcContextMocks: KcContextBase[] = [
|
||||
"email": {
|
||||
"ignore.empty.value": true,
|
||||
},
|
||||
"pattern": {
|
||||
"ignore.empty.value": true,
|
||||
"pattern": "gmail\\.com$",
|
||||
},
|
||||
},
|
||||
"displayName": "${email}",
|
||||
"annotations": {},
|
||||
@ -273,29 +278,6 @@ export const kcContextMocks: KcContextBase[] = [
|
||||
"readOnly": false,
|
||||
"name": "lastName",
|
||||
},
|
||||
{
|
||||
"validators": {
|
||||
"length": {
|
||||
"ignore.empty.value": true,
|
||||
"min": "3",
|
||||
"max": "9",
|
||||
},
|
||||
"up-immutable-attribute": {},
|
||||
"up-attribute-required-by-metadata-value": {},
|
||||
"email": {
|
||||
"ignore.empty.value": true,
|
||||
},
|
||||
},
|
||||
"displayName": "${foo}",
|
||||
"annotations": {
|
||||
"this_is_second_key": "this_is_second_value",
|
||||
"this_is_first_key": "this_is_first_value",
|
||||
},
|
||||
"required": true,
|
||||
"groupAnnotations": {},
|
||||
"readOnly": false,
|
||||
"name": "foo",
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
|
@ -1,7 +1,27 @@
|
||||
import { kcMessages } from "../generated_kcMessages/15.0.2/login";
|
||||
import { kcMessages as kcMessagesBase } from "../generated_kcMessages/15.0.2/login";
|
||||
import { Evt } from "evt";
|
||||
import { objectKeys } from "tsafe/objectKeys";
|
||||
|
||||
const kcMessages = {
|
||||
...kcMessagesBase,
|
||||
"en": {
|
||||
...kcMessagesBase["en"],
|
||||
"shouldBeEqual": "{0} should be equal to {1}",
|
||||
"shouldBeDifferent": "{0} should be different to {1}",
|
||||
"shouldMatchPattern": "Pattern should match: `/{0}/`",
|
||||
"mustBeAnInteger": "Must be an integer",
|
||||
},
|
||||
"fr": {
|
||||
...kcMessagesBase["fr"],
|
||||
/* spell-checker: disable */
|
||||
"shouldBeEqual": "{0} doit être egale à {1}",
|
||||
"shouldBeDifferent": "{0} doit être différent de {1}",
|
||||
"shouldMatchPattern": "Dois respecter le schéma: `/{0}/`",
|
||||
"mustBeAnInteger": "Doit être un nombre entiers",
|
||||
/* spell-checker: enable */
|
||||
},
|
||||
};
|
||||
|
||||
export const evtTermsUpdated = Evt.asNonPostable(Evt.create<void>());
|
||||
|
||||
(["termsText", "doAccept", "doDecline", "termsTitle"] as const).forEach(key =>
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { createUseGlobalState } from "powerhooks/useGlobalState";
|
||||
import { getKcContext } from "../getKcContext";
|
||||
import { getKcContextFromWindow } from "../getKcContext/getKcContextFromWindow";
|
||||
import { getBestMatchAmongKcLanguageTag } from "./KcLanguageTag";
|
||||
import type { StatefulEvt } from "powerhooks";
|
||||
import { KcLanguageTag } from "./KcLanguageTag";
|
||||
@ -8,7 +8,7 @@ import { KcLanguageTag } from "./KcLanguageTag";
|
||||
const wrap = createUseGlobalState(
|
||||
"kcLanguageTag",
|
||||
() => {
|
||||
const { kcContext } = getKcContext();
|
||||
const kcContext = getKcContextFromWindow();
|
||||
|
||||
const languageLike = kcContext?.locale?.current ?? (typeof navigator === "undefined" ? undefined : navigator.language);
|
||||
|
||||
|
@ -1,21 +1,95 @@
|
||||
import { useCallback, useReducer } from "react";
|
||||
import "minimal-polyfills/Object.fromEntries";
|
||||
import { useReducer } from "react";
|
||||
import { useKcLanguageTag } from "./useKcLanguageTag";
|
||||
import { kcMessages, evtTermsUpdated } from "./kcMessages/login";
|
||||
import { useEvt } from "evt/hooks";
|
||||
//NOTE for later: https://github.com/remarkjs/react-markdown/blob/236182ecf30bd89c1e5a7652acaf8d0bf81e6170/src/renderers.js#L7-L35
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { id } from "tsafe/id";
|
||||
import { useGuaranteedMemo } from "powerhooks/useGuaranteedMemo";
|
||||
|
||||
export { kcMessages };
|
||||
|
||||
export type MessageKey = keyof typeof kcMessages["en"];
|
||||
|
||||
function resolveMsg<Key extends string, DoRenderMarkdown extends boolean>(props: {
|
||||
key: Key;
|
||||
args: (string | undefined)[];
|
||||
kcLanguageTag: string;
|
||||
doRenderMarkdown: DoRenderMarkdown;
|
||||
}): Key extends MessageKey ? (DoRenderMarkdown extends true ? JSX.Element : string) : undefined {
|
||||
const { key, args, kcLanguageTag, doRenderMarkdown } = props;
|
||||
|
||||
let str = kcMessages[kcLanguageTag as any as "en"][key as MessageKey] ?? kcMessages["en"][key as MessageKey];
|
||||
|
||||
if (str === undefined) {
|
||||
return undefined as any;
|
||||
}
|
||||
|
||||
str = (() => {
|
||||
const startIndex = str
|
||||
.match(/(?<={)[0-9]+(?=})/g)
|
||||
?.map(g => parseInt(g))
|
||||
.sort((a, b) => a - b)[0];
|
||||
|
||||
if (startIndex === undefined) {
|
||||
return str;
|
||||
}
|
||||
|
||||
args.forEach((arg, i) => {
|
||||
if (arg === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
str = str.replace(new RegExp(`\\{${i + startIndex}\\}`, "g"), arg);
|
||||
});
|
||||
|
||||
return str;
|
||||
})();
|
||||
|
||||
return (
|
||||
doRenderMarkdown ? (
|
||||
<ReactMarkdown allowDangerousHtml renderers={key === "termsText" ? undefined : { "paragraph": "span" }}>
|
||||
{str}
|
||||
</ReactMarkdown>
|
||||
) : (
|
||||
str
|
||||
)
|
||||
) as any;
|
||||
}
|
||||
|
||||
function resolveMsgAdvanced<Key extends string, DoRenderMarkdown extends boolean>(props: {
|
||||
key: Key;
|
||||
args: (string | undefined)[];
|
||||
kcLanguageTag: string;
|
||||
doRenderMarkdown: DoRenderMarkdown;
|
||||
}): DoRenderMarkdown extends true ? JSX.Element : string {
|
||||
const { key, args, kcLanguageTag, doRenderMarkdown } = props;
|
||||
|
||||
const match = key.match(/^\$\{([^{]+)\}$/);
|
||||
|
||||
const resolvedKey = match === null ? key : match[1];
|
||||
|
||||
const out = resolveMsg({
|
||||
"key": resolvedKey,
|
||||
args,
|
||||
kcLanguageTag,
|
||||
doRenderMarkdown,
|
||||
});
|
||||
|
||||
return (out !== undefined ? out : match === null ? doRenderMarkdown ? <span>{key}</span> : key : undefined) as any;
|
||||
}
|
||||
|
||||
/**
|
||||
* When the language is switched the page is reloaded, this may appear
|
||||
* as a bug as you might notice that the language successfully switch before
|
||||
* reload.
|
||||
* However we need to tell Keycloak that the user have changed the language
|
||||
* during login so we can retrieve the "local" field of the JWT encoded accessToken.
|
||||
* https://user-images.githubusercontent.com/6702424/138096682-351bb61f-f24e-4caf-91b7-cca8cfa2cb58.mov
|
||||
*
|
||||
* advancedMsg("${access-denied}") === advancedMsg("access-denied") === msg("access-denied")
|
||||
* advancedMsg("${not-a-message-key}") === advancedMsg(not-a-message-key") === "not-a-message-key"
|
||||
*
|
||||
*/
|
||||
export function useKcMessage() {
|
||||
const { kcLanguageTag } = useKcLanguageTag();
|
||||
@ -24,46 +98,17 @@ export function useKcMessage() {
|
||||
|
||||
useEvt(ctx => evtTermsUpdated.attach(ctx, forceUpdate), []);
|
||||
|
||||
const msgStr = useCallback(
|
||||
(key: MessageKey, ...args: (string | undefined)[]): string => {
|
||||
let str: string = kcMessages[kcLanguageTag as any as "en"][key] ?? kcMessages["en"][key];
|
||||
|
||||
args.forEach((arg, i) => {
|
||||
if (arg === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
str = str.replace(new RegExp(`\\{${i}\\}`, "g"), arg);
|
||||
});
|
||||
|
||||
return str;
|
||||
},
|
||||
return useGuaranteedMemo(
|
||||
() => ({
|
||||
"msgStr": (key: MessageKey, ...args: (string | undefined)[]): string =>
|
||||
resolveMsg({ key, args, kcLanguageTag, "doRenderMarkdown": false }),
|
||||
"msg": (key: MessageKey, ...args: (string | undefined)[]): JSX.Element =>
|
||||
resolveMsg({ key, args, kcLanguageTag, "doRenderMarkdown": true }),
|
||||
"advancedMsg": <Key extends string>(key: Key, ...args: (string | undefined)[]): JSX.Element =>
|
||||
resolveMsgAdvanced({ key, args, kcLanguageTag, "doRenderMarkdown": true }),
|
||||
"advancedMsgStr": <Key extends string>(key: Key, ...args: (string | undefined)[]): string =>
|
||||
resolveMsgAdvanced({ key, args, kcLanguageTag, "doRenderMarkdown": false }),
|
||||
}),
|
||||
[kcLanguageTag, trigger],
|
||||
);
|
||||
|
||||
const msg = useCallback<(...args: Parameters<typeof msgStr>) => JSX.Element>(
|
||||
(key, ...args) => (
|
||||
<ReactMarkdown allowDangerousHtml renderers={key === "termsText" ? undefined : { "paragraph": "span" }}>
|
||||
{msgStr(key, ...args)}
|
||||
</ReactMarkdown>
|
||||
),
|
||||
[msgStr],
|
||||
);
|
||||
|
||||
const advancedMsg = useCallback(
|
||||
(key: string): string | undefined => {
|
||||
const match = key.match(/^\$\{([^{]+)\}$/);
|
||||
|
||||
const resolvedKey = match === null ? key : match[1];
|
||||
|
||||
const out =
|
||||
id<Record<string, string | undefined>>(kcMessages[kcLanguageTag])[resolvedKey] ??
|
||||
id<Record<string, string | undefined>>(kcMessages["en"])[resolvedKey];
|
||||
|
||||
return out !== undefined ? out : match === null ? key : undefined;
|
||||
},
|
||||
[msgStr],
|
||||
);
|
||||
|
||||
return { msg, msgStr, advancedMsg };
|
||||
}
|
||||
|
@ -14,5 +14,6 @@ export * from "./components/Error";
|
||||
export * from "./components/LoginResetPassword";
|
||||
export * from "./components/LoginVerifyEmail";
|
||||
export * from "./keycloakJsAdapter";
|
||||
export * from "./useFormValidationSlice";
|
||||
|
||||
export * from "./tools/assert";
|
||||
|
64
src/lib/tools/Array.prototype.every.ts
Normal file
64
src/lib/tools/Array.prototype.every.ts
Normal file
@ -0,0 +1,64 @@
|
||||
if (!Array.prototype.every) {
|
||||
Array.prototype.every = function (callbackfn: any, thisArg: any) {
|
||||
"use strict";
|
||||
var T, k;
|
||||
|
||||
if (this == null) {
|
||||
throw new TypeError("this is null or not defined");
|
||||
}
|
||||
|
||||
// 1. Let O be the result of calling ToObject passing the this
|
||||
// value as the argument.
|
||||
var O = Object(this);
|
||||
|
||||
// 2. Let lenValue be the result of calling the Get internal method
|
||||
// of O with the argument "length".
|
||||
// 3. Let len be ToUint32(lenValue).
|
||||
var len = O.length >>> 0;
|
||||
|
||||
// 4. If IsCallable(callbackfn) is false, throw a TypeError exception.
|
||||
if (typeof callbackfn !== "function" && Object.prototype.toString.call(callbackfn) !== "[object Function]") {
|
||||
throw new TypeError();
|
||||
}
|
||||
|
||||
// 5. If thisArg was supplied, let T be thisArg; else let T be undefined.
|
||||
if (arguments.length > 1) {
|
||||
T = thisArg;
|
||||
}
|
||||
|
||||
// 6. Let k be 0.
|
||||
k = 0;
|
||||
|
||||
// 7. Repeat, while k < len
|
||||
while (k < len) {
|
||||
var kValue;
|
||||
|
||||
// a. Let Pk be ToString(k).
|
||||
// This is implicit for LHS operands of the in operator
|
||||
// b. Let kPresent be the result of calling the HasProperty internal
|
||||
// method of O with argument Pk.
|
||||
// This step can be combined with c
|
||||
// c. If kPresent is true, then
|
||||
if (k in O) {
|
||||
var testResult;
|
||||
// i. Let kValue be the result of calling the Get internal method
|
||||
// of O with argument Pk.
|
||||
kValue = O[k];
|
||||
|
||||
// ii. Let testResult be the result of calling the Call internal method
|
||||
// of callbackfn with T as the this value if T is not undefined
|
||||
// else is the result of calling callbackfn
|
||||
// and argument list containing kValue, k, and O.
|
||||
if (T) testResult = callbackfn.call(T, kValue, k, O);
|
||||
else testResult = callbackfn(kValue, k, O);
|
||||
|
||||
// iii. If ToBoolean(testResult) is false, return false.
|
||||
if (!testResult) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
k++;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
}
|
9
src/lib/tools/HTMLElement.prototype.prepend.ts
Normal file
9
src/lib/tools/HTMLElement.prototype.prepend.ts
Normal file
@ -0,0 +1,9 @@
|
||||
if (!HTMLElement.prototype.prepend) {
|
||||
HTMLElement.prototype.prepend = function (childNode) {
|
||||
if (typeof childNode === "string") {
|
||||
throw new Error("Error with HTMLElement.prototype.appendFirst polyfill");
|
||||
}
|
||||
|
||||
this.insertBefore(childNode, this.firstChild);
|
||||
};
|
||||
}
|
@ -1,9 +1,12 @@
|
||||
import { assert } from "tsafe/assert";
|
||||
import { is } from "tsafe/is";
|
||||
import { deepClone } from "./deepClone";
|
||||
|
||||
//Warning: Be mindful that because of array this is not idempotent.
|
||||
export function deepAssign(params: { target: Record<string, unknown>; source: Record<string, unknown> }) {
|
||||
const { target, source } = params;
|
||||
const { target } = params;
|
||||
|
||||
const source = deepClone(params.source);
|
||||
|
||||
Object.keys(source).forEach(key => {
|
||||
var dereferencedSource = source[key];
|
||||
|
@ -1,3 +1,17 @@
|
||||
export function deepClone<T>(arg: T): T {
|
||||
return JSON.parse(JSON.stringify(arg));
|
||||
import "minimal-polyfills/Object.fromEntries";
|
||||
|
||||
export function deepClone<T>(o: T): T {
|
||||
if (!(o instanceof Object)) {
|
||||
return o;
|
||||
}
|
||||
|
||||
if (typeof o === "function") {
|
||||
return o;
|
||||
}
|
||||
|
||||
if (o instanceof Array) {
|
||||
return o.map(deepClone) as any;
|
||||
}
|
||||
|
||||
return Object.fromEntries(Object.entries(o).map(([key, value]) => [key, deepClone(value)])) as any;
|
||||
}
|
||||
|
2
src/lib/tools/emailRegExp.ts
Normal file
2
src/lib/tools/emailRegExp.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export const emailRegexp =
|
||||
/^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
|
@ -1,10 +1,12 @@
|
||||
import "./HTMLElement.prototype.prepend";
|
||||
import { Deferred } from "evt/tools/Deferred";
|
||||
|
||||
export function appendHead(
|
||||
export function headInsert(
|
||||
params:
|
||||
| {
|
||||
type: "css";
|
||||
href: string;
|
||||
position: "append" | "prepend";
|
||||
}
|
||||
| {
|
||||
type: "javascript";
|
||||
@ -46,7 +48,23 @@ export function appendHead(
|
||||
})(),
|
||||
);
|
||||
|
||||
document.getElementsByTagName("head")[0].appendChild(htmlElement);
|
||||
document.getElementsByTagName("head")[0][
|
||||
(() => {
|
||||
switch (params.type) {
|
||||
case "javascript":
|
||||
return "appendChild";
|
||||
case "css":
|
||||
return (() => {
|
||||
switch (params.position) {
|
||||
case "append":
|
||||
return "appendChild";
|
||||
case "prepend":
|
||||
return "prepend";
|
||||
}
|
||||
})();
|
||||
}
|
||||
})()
|
||||
](htmlElement);
|
||||
|
||||
return dLoaded.pr;
|
||||
}
|
450
src/lib/useFormValidationSlice.tsx
Normal file
450
src/lib/useFormValidationSlice.tsx
Normal file
@ -0,0 +1,450 @@
|
||||
import "./tools/Array.prototype.every";
|
||||
import { useMemo, useReducer, Fragment } from "react";
|
||||
import type { KcContextBase, Validators, Attribute } from "./getKcContext/KcContextBase";
|
||||
import { useKcMessage } from "./i18n/useKcMessage";
|
||||
import { useConstCallback } from "powerhooks/useConstCallback";
|
||||
import { id } from "tsafe/id";
|
||||
import type { MessageKey } from "./i18n/useKcMessage";
|
||||
import { emailRegexp } from "./tools/emailRegExp";
|
||||
|
||||
export type KcContextLike = {
|
||||
messagesPerField: Pick<KcContextBase.Common["messagesPerField"], "existsError" | "get">;
|
||||
attributes: { name: string; value?: string; validators: Validators }[];
|
||||
passwordRequired: boolean;
|
||||
realm: { registrationEmailAsUsername: boolean };
|
||||
};
|
||||
|
||||
export function useGetErrors(params: {
|
||||
kcContext: {
|
||||
messagesPerField: Pick<KcContextBase.Common["messagesPerField"], "existsError" | "get">;
|
||||
profile: {
|
||||
attributes: { name: string; value?: string; validators: Validators }[];
|
||||
};
|
||||
};
|
||||
}) {
|
||||
const {
|
||||
kcContext: {
|
||||
messagesPerField,
|
||||
profile: { attributes },
|
||||
},
|
||||
} = params;
|
||||
|
||||
const { msg, msgStr, advancedMsg, advancedMsgStr } = useKcMessage();
|
||||
|
||||
const getErrors = useConstCallback((params: { name: string; fieldValueByAttributeName: Record<string, { value: string }> }) => {
|
||||
const { name, fieldValueByAttributeName } = params;
|
||||
|
||||
const { value } = fieldValueByAttributeName[name];
|
||||
|
||||
const { value: defaultValue, validators } = attributes.find(attribute => attribute.name === name)!;
|
||||
|
||||
block: {
|
||||
if (defaultValue !== value) {
|
||||
break block;
|
||||
}
|
||||
|
||||
let doesErrorExist: boolean;
|
||||
|
||||
try {
|
||||
doesErrorExist = messagesPerField.existsError(name);
|
||||
} catch {
|
||||
break block;
|
||||
}
|
||||
|
||||
if (!doesErrorExist) {
|
||||
break block;
|
||||
}
|
||||
|
||||
const errorMessageStr = messagesPerField.get(name);
|
||||
|
||||
return [
|
||||
{
|
||||
"validatorName": undefined,
|
||||
errorMessageStr,
|
||||
"errorMessage": <span key={0}>{errorMessageStr}</span>,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const errors: {
|
||||
errorMessage: JSX.Element;
|
||||
errorMessageStr: string;
|
||||
validatorName: keyof Validators | undefined;
|
||||
}[] = [];
|
||||
|
||||
scope: {
|
||||
const validatorName = "length";
|
||||
|
||||
const validator = validators[validatorName];
|
||||
|
||||
if (validator === undefined) {
|
||||
break scope;
|
||||
}
|
||||
|
||||
const { "ignore.empty.value": ignoreEmptyValue = false, max, min } = validator;
|
||||
|
||||
if (ignoreEmptyValue && value === "") {
|
||||
break scope;
|
||||
}
|
||||
|
||||
if (max !== undefined && value.length > parseInt(max)) {
|
||||
const msgArgs = ["error-invalid-length-too-long", max] as const;
|
||||
|
||||
errors.push({
|
||||
"errorMessage": <Fragment key={errors.length}>{msg(...msgArgs)}</Fragment>,
|
||||
"errorMessageStr": msgStr(...msgArgs),
|
||||
validatorName,
|
||||
});
|
||||
}
|
||||
|
||||
if (min !== undefined && value.length < parseInt(min)) {
|
||||
const msgArgs = ["error-invalid-length-too-short", min] as const;
|
||||
|
||||
errors.push({
|
||||
"errorMessage": <Fragment key={errors.length}>{msg(...msgArgs)}</Fragment>,
|
||||
"errorMessageStr": msgStr(...msgArgs),
|
||||
validatorName,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
scope: {
|
||||
const validatorName = "_compareToOther";
|
||||
|
||||
const validator = validators[validatorName];
|
||||
|
||||
if (validator === undefined) {
|
||||
break scope;
|
||||
}
|
||||
|
||||
const { "ignore.empty.value": ignoreEmptyValue = false, name: otherName, shouldBe, "error-message": errorMessageKey } = validator;
|
||||
|
||||
if (ignoreEmptyValue && value === "") {
|
||||
break scope;
|
||||
}
|
||||
|
||||
const { value: otherValue } = fieldValueByAttributeName[otherName];
|
||||
|
||||
const isValid = (() => {
|
||||
switch (shouldBe) {
|
||||
case "different":
|
||||
return otherValue !== value;
|
||||
case "equal":
|
||||
return otherValue === value;
|
||||
}
|
||||
})();
|
||||
|
||||
if (isValid) {
|
||||
break scope;
|
||||
}
|
||||
|
||||
const msgArg = [
|
||||
errorMessageKey ??
|
||||
id<MessageKey>(
|
||||
(() => {
|
||||
switch (shouldBe) {
|
||||
case "equal":
|
||||
return "shouldBeEqual";
|
||||
case "different":
|
||||
return "shouldBeDifferent";
|
||||
}
|
||||
})(),
|
||||
),
|
||||
otherName,
|
||||
name,
|
||||
shouldBe,
|
||||
] as const;
|
||||
|
||||
errors.push({
|
||||
validatorName,
|
||||
"errorMessage": <Fragment key={errors.length}>{advancedMsg(...msgArg)}</Fragment>,
|
||||
"errorMessageStr": advancedMsgStr(...msgArg),
|
||||
});
|
||||
}
|
||||
|
||||
scope: {
|
||||
const validatorName = "pattern";
|
||||
|
||||
const validator = validators[validatorName];
|
||||
|
||||
if (validator === undefined) {
|
||||
break scope;
|
||||
}
|
||||
|
||||
const { "ignore.empty.value": ignoreEmptyValue = false, pattern, "error-message": errorMessageKey } = validator;
|
||||
|
||||
if (ignoreEmptyValue && value === "") {
|
||||
break scope;
|
||||
}
|
||||
|
||||
if (new RegExp(pattern).test(value)) {
|
||||
break scope;
|
||||
}
|
||||
|
||||
const msgArgs = [errorMessageKey ?? id<MessageKey>("shouldMatchPattern"), pattern] as const;
|
||||
|
||||
errors.push({
|
||||
validatorName,
|
||||
"errorMessage": <Fragment key={errors.length}>{advancedMsg(...msgArgs)}</Fragment>,
|
||||
"errorMessageStr": advancedMsgStr(...msgArgs),
|
||||
});
|
||||
}
|
||||
|
||||
scope: {
|
||||
if ([...errors].reverse()[0]?.validatorName === "pattern") {
|
||||
break scope;
|
||||
}
|
||||
|
||||
const validatorName = "email";
|
||||
|
||||
const validator = validators[validatorName];
|
||||
|
||||
if (validator === undefined) {
|
||||
break scope;
|
||||
}
|
||||
|
||||
const { "ignore.empty.value": ignoreEmptyValue = false } = validator;
|
||||
|
||||
if (ignoreEmptyValue && value === "") {
|
||||
break scope;
|
||||
}
|
||||
|
||||
if (emailRegexp.test(value)) {
|
||||
break scope;
|
||||
}
|
||||
|
||||
const msgArgs = ["invalidEmailMessage"] as const;
|
||||
|
||||
errors.push({
|
||||
validatorName,
|
||||
"errorMessage": <Fragment key={errors.length}>{msg(...msgArgs)}</Fragment>,
|
||||
"errorMessageStr": msgStr(...msgArgs),
|
||||
});
|
||||
}
|
||||
|
||||
scope: {
|
||||
const validatorName = "integer";
|
||||
|
||||
const validator = validators[validatorName];
|
||||
|
||||
if (validator === undefined) {
|
||||
break scope;
|
||||
}
|
||||
|
||||
const { "ignore.empty.value": ignoreEmptyValue = false, max, min } = validator;
|
||||
|
||||
if (ignoreEmptyValue && value === "") {
|
||||
break scope;
|
||||
}
|
||||
|
||||
const intValue = parseInt(value);
|
||||
|
||||
if (isNaN(intValue)) {
|
||||
const msgArgs = ["mustBeAnInteger"] as const;
|
||||
|
||||
errors.push({
|
||||
validatorName,
|
||||
"errorMessage": <Fragment key={errors.length}>{msg(...msgArgs)}</Fragment>,
|
||||
"errorMessageStr": msgStr(...msgArgs),
|
||||
});
|
||||
|
||||
break scope;
|
||||
}
|
||||
|
||||
if (max !== undefined && intValue > parseInt(max)) {
|
||||
const msgArgs = ["error-number-out-of-range-too-big", max] as const;
|
||||
|
||||
errors.push({
|
||||
validatorName,
|
||||
"errorMessage": <Fragment key={errors.length}>{msg(...msgArgs)}</Fragment>,
|
||||
"errorMessageStr": msgStr(...msgArgs),
|
||||
});
|
||||
|
||||
break scope;
|
||||
}
|
||||
|
||||
if (min !== undefined && intValue < parseInt(min)) {
|
||||
const msgArgs = ["error-number-out-of-range-too-small", min] as const;
|
||||
|
||||
errors.push({
|
||||
validatorName,
|
||||
"errorMessage": <Fragment key={errors.length}>{msg(...msgArgs)}</Fragment>,
|
||||
"errorMessageStr": msgStr(...msgArgs),
|
||||
});
|
||||
|
||||
break scope;
|
||||
}
|
||||
}
|
||||
|
||||
//TODO: Implement missing validators.
|
||||
|
||||
return errors;
|
||||
});
|
||||
|
||||
return { getErrors };
|
||||
}
|
||||
|
||||
export function useFormValidationSlice(params: {
|
||||
kcContext: {
|
||||
messagesPerField: Pick<KcContextBase.Common["messagesPerField"], "existsError" | "get">;
|
||||
profile: {
|
||||
attributes: Attribute[];
|
||||
};
|
||||
passwordRequired: boolean;
|
||||
realm: { registrationEmailAsUsername: boolean };
|
||||
};
|
||||
/** NOTE: Try to avoid passing a new ref every render for better performances. */
|
||||
passwordValidators?: Validators;
|
||||
}) {
|
||||
const {
|
||||
kcContext,
|
||||
passwordValidators = {
|
||||
"length": {
|
||||
"ignore.empty.value": true,
|
||||
"min": "4",
|
||||
},
|
||||
},
|
||||
} = params;
|
||||
|
||||
const attributesWithPassword = useMemo(
|
||||
() =>
|
||||
!kcContext.passwordRequired
|
||||
? kcContext.profile.attributes
|
||||
: (() => {
|
||||
const name = kcContext.realm.registrationEmailAsUsername ? "email" : "username";
|
||||
|
||||
return kcContext.profile.attributes.reduce<Attribute[]>(
|
||||
(prev, curr) => [
|
||||
...prev,
|
||||
...(curr.name !== name
|
||||
? [curr]
|
||||
: [
|
||||
curr,
|
||||
id<Attribute>({
|
||||
"name": "password",
|
||||
"displayName": id<`\${${MessageKey}}`>("${password}"),
|
||||
"required": true,
|
||||
"readOnly": false,
|
||||
"validators": passwordValidators,
|
||||
"annotations": {},
|
||||
"groupAnnotations": {},
|
||||
"autocomplete": "new-password",
|
||||
}),
|
||||
id<Attribute>({
|
||||
"name": "password-confirm",
|
||||
"displayName": id<`\${${MessageKey}}`>("${passwordConfirm}"),
|
||||
"required": true,
|
||||
"readOnly": false,
|
||||
"validators": {
|
||||
"_compareToOther": {
|
||||
"name": "password",
|
||||
"ignore.empty.value": true,
|
||||
"shouldBe": "equal",
|
||||
"error-message": id<`\${${MessageKey}}`>("${invalidPasswordConfirmMessage}"),
|
||||
},
|
||||
},
|
||||
"annotations": {},
|
||||
"groupAnnotations": {},
|
||||
"autocomplete": "new-password",
|
||||
}),
|
||||
]),
|
||||
],
|
||||
[],
|
||||
);
|
||||
})(),
|
||||
[kcContext, passwordValidators],
|
||||
);
|
||||
|
||||
const { getErrors } = useGetErrors({
|
||||
"kcContext": {
|
||||
"messagesPerField": kcContext.messagesPerField,
|
||||
"profile": {
|
||||
"attributes": attributesWithPassword,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const initialInternalState = useMemo(
|
||||
() =>
|
||||
Object.fromEntries(
|
||||
attributesWithPassword
|
||||
.map(attribute => ({
|
||||
attribute,
|
||||
"errors": getErrors({
|
||||
"name": attribute.name,
|
||||
"fieldValueByAttributeName": Object.fromEntries(
|
||||
attributesWithPassword.map(({ name, value }) => [name, { "value": value ?? "" }]),
|
||||
),
|
||||
}),
|
||||
}))
|
||||
.map(({ attribute, errors }) => [
|
||||
attribute.name,
|
||||
{
|
||||
"value": attribute.value ?? "",
|
||||
errors,
|
||||
"doDisplayPotentialErrorMessages": errors.length !== 0,
|
||||
},
|
||||
]),
|
||||
),
|
||||
[attributesWithPassword],
|
||||
);
|
||||
|
||||
type InternalState = typeof initialInternalState;
|
||||
|
||||
const [formValidationInternalState, formValidationReducer] = useReducer(
|
||||
(
|
||||
state: InternalState,
|
||||
params:
|
||||
| {
|
||||
action: "update value";
|
||||
name: string;
|
||||
newValue: string;
|
||||
}
|
||||
| {
|
||||
action: "focus lost";
|
||||
name: string;
|
||||
},
|
||||
): InternalState => ({
|
||||
...state,
|
||||
[params.name]: {
|
||||
...state[params.name],
|
||||
...(() => {
|
||||
switch (params.action) {
|
||||
case "focus lost":
|
||||
return { "doDisplayPotentialErrorMessages": true };
|
||||
case "update value":
|
||||
return {
|
||||
"value": params.newValue,
|
||||
"errors": getErrors({
|
||||
"name": params.name,
|
||||
"fieldValueByAttributeName": {
|
||||
...state,
|
||||
[params.name]: { "value": params.newValue },
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
})(),
|
||||
},
|
||||
}),
|
||||
initialInternalState,
|
||||
);
|
||||
|
||||
const formValidationState = useMemo(
|
||||
() => ({
|
||||
"fieldStateByAttributeName": Object.fromEntries(
|
||||
Object.entries(formValidationInternalState).map(([name, { value, errors, doDisplayPotentialErrorMessages }]) => [
|
||||
name,
|
||||
{ value, "displayableErrors": doDisplayPotentialErrorMessages ? errors : [] },
|
||||
]),
|
||||
),
|
||||
"isFormSubmittable": Object.entries(formValidationInternalState).every(
|
||||
([name, { value, errors }]) =>
|
||||
errors.length === 0 && (value !== "" || !attributesWithPassword.find(attribute => attribute.name === name)!.required),
|
||||
),
|
||||
}),
|
||||
[formValidationInternalState, attributesWithPassword],
|
||||
);
|
||||
|
||||
return { formValidationState, formValidationReducer, attributesWithPassword };
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
import { getKcContext } from "../../lib/getKcContext";
|
||||
import type { KcContextBase } from "../../lib/getKcContext";
|
||||
import type { ExtendsKcContextBase } from "../../lib/getKcContext/getKcContext";
|
||||
import type { ExtendsKcContextBase } from "../../lib/getKcContext";
|
||||
import { same } from "evt/tools/inDepth";
|
||||
import { assert } from "tsafe/assert";
|
||||
import type { Equals } from "tsafe";
|
||||
|
38
yarn.lock
38
yarn.lock
@ -771,11 +771,6 @@ inherits@2, inherits@^2.0.1, inherits@~2.0.1, inherits@~2.0.3:
|
||||
resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
|
||||
integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
|
||||
|
||||
inherits@2.0.3:
|
||||
version "2.0.3"
|
||||
resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de"
|
||||
integrity sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=
|
||||
|
||||
inline-style-parser@0.1.1:
|
||||
version "0.1.1"
|
||||
resolved "https://registry.yarnpkg.com/inline-style-parser/-/inline-style-parser-0.1.1.tgz#ec8a3b429274e9c0a1f1c4ffa9453a7fef72cea1"
|
||||
@ -1174,6 +1169,11 @@ parse5@^6.0.1:
|
||||
resolved "https://registry.yarnpkg.com/parse5/-/parse5-6.0.1.tgz#e1a1c085c569b3dc08321184f19a39cc27f7c30b"
|
||||
integrity sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==
|
||||
|
||||
path-browserify@^1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/path-browserify/-/path-browserify-1.0.1.tgz#d98454a9c3753d5790860f16f68867b9e46be1fd"
|
||||
integrity sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==
|
||||
|
||||
path-exists@^4.0.0:
|
||||
version "4.0.0"
|
||||
resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3"
|
||||
@ -1194,14 +1194,6 @@ path-type@^4.0.0:
|
||||
resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b"
|
||||
integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==
|
||||
|
||||
path@^0.12.7:
|
||||
version "0.12.7"
|
||||
resolved "https://registry.yarnpkg.com/path/-/path-0.12.7.tgz#d4dc2a506c4ce2197eb481ebfcd5b36c0140b10f"
|
||||
integrity sha1-1NwqUGxM4hl+tIHr/NWzbAFAsQ8=
|
||||
dependencies:
|
||||
process "^0.11.1"
|
||||
util "^0.10.3"
|
||||
|
||||
picomatch@^2.2.3:
|
||||
version "2.3.0"
|
||||
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.0.tgz#f1f061de8f6a4bf022892e2d128234fb98302972"
|
||||
@ -1221,10 +1213,10 @@ please-upgrade-node@^3.2.0:
|
||||
dependencies:
|
||||
semver-compare "^1.0.0"
|
||||
|
||||
powerhooks@^0.9.4:
|
||||
version "0.9.4"
|
||||
resolved "https://registry.yarnpkg.com/powerhooks/-/powerhooks-0.9.4.tgz#a93b8890dbbf32a0a3373def2bda4e223ad3a61c"
|
||||
integrity sha512-MuA94uuDScC1skengnbY1TluH+5YOvAaDn818A94ABv0geFq8mDTbFiZk2/kkkhHvM3yeSre/SzRQZ0Sbe8XhA==
|
||||
powerhooks@^0.11.0:
|
||||
version "0.11.0"
|
||||
resolved "https://registry.yarnpkg.com/powerhooks/-/powerhooks-0.11.0.tgz#923749ed405a1189759cd3f16d36bd5efacfd40c"
|
||||
integrity sha512-I48J5vJqlTRiR3eH6svxiIYLutdedu2YEd3uhCmK+pRg2jcuourVwp1UYORI/EzbKC9vv0X/0Vd/SZ6e07rYtA==
|
||||
dependencies:
|
||||
evt "2.0.0-beta.38"
|
||||
memoizee "^0.4.15"
|
||||
@ -1241,11 +1233,6 @@ process-nextick-args@~2.0.0:
|
||||
resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2"
|
||||
integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==
|
||||
|
||||
process@^0.11.1:
|
||||
version "0.11.10"
|
||||
resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182"
|
||||
integrity sha1-czIwDoQBYb2j5podHZGn1LwW8YI=
|
||||
|
||||
prop-types@^15.7.2:
|
||||
version "15.7.2"
|
||||
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5"
|
||||
@ -1677,13 +1664,6 @@ util-deprecate@~1.0.1:
|
||||
resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
|
||||
integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=
|
||||
|
||||
util@^0.10.3:
|
||||
version "0.10.4"
|
||||
resolved "https://registry.yarnpkg.com/util/-/util-0.10.4.tgz#3aa0125bfe668a4672de58857d3ace27ecb76901"
|
||||
integrity sha512-0Pm9hTQ3se5ll1XihRic3FDIku70C+iHUdT/W926rSgHV5QgXsYbKZN8MSC3tJtSkhuROzvsQjAaFENRXr+19A==
|
||||
dependencies:
|
||||
inherits "2.0.3"
|
||||
|
||||
vfile-message@^2.0.0:
|
||||
version "2.0.4"
|
||||
resolved "https://registry.yarnpkg.com/vfile-message/-/vfile-message-2.0.4.tgz#5b43b88171d409eae58477d13f23dd41d52c371a"
|
||||
|
Reference in New Issue
Block a user