Migrate to Keycloakify v10
This commit is contained in:
parent
030836d534
commit
59008f5b87
|
@ -1,23 +1,23 @@
|
||||||
{
|
{
|
||||||
"name": "Keycloakify Starter Devcontainer",
|
"name": "Keycloakify Starter Devcontainer",
|
||||||
"image": "mcr.microsoft.com/devcontainers/typescript-node:1-20-bookworm",
|
"image": "mcr.microsoft.com/devcontainers/typescript-node:1-20-bookworm",
|
||||||
"features": {
|
"features": {
|
||||||
"ghcr.io/devcontainers/features/docker-in-docker:2": {
|
"ghcr.io/devcontainers/features/docker-in-docker:2": {
|
||||||
"moby": true,
|
"moby": true,
|
||||||
"installDockerBuildx": true,
|
"installDockerBuildx": true,
|
||||||
"version": "latest",
|
"version": "latest",
|
||||||
"dockerDashComposeVersion": "none"
|
"dockerDashComposeVersion": "none"
|
||||||
|
},
|
||||||
|
"ghcr.io/devcontainers-contrib/features/maven-sdkman:2": {
|
||||||
|
"version": "latest",
|
||||||
|
"jdkVersion": "latest",
|
||||||
|
"jdkDistro": "ms"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"ghcr.io/devcontainers-contrib/features/maven-sdkman:2": {
|
"postCreateCommand": "yarn install",
|
||||||
"version": "latest",
|
"customizations": {
|
||||||
"jdkVersion": "latest",
|
"vscode": {
|
||||||
"jdkDistro": "ms"
|
"extensions": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"postCreateCommand": "yarn install",
|
|
||||||
"customizations": {
|
|
||||||
"vscode": {
|
|
||||||
"extensions": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,25 +1,27 @@
|
||||||
module.exports = {
|
module.exports = {
|
||||||
root: true,
|
root: true,
|
||||||
env: { browser: true, es2020: true },
|
env: { browser: true, es2020: true },
|
||||||
extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended', 'plugin:react-hooks/recommended', 'plugin:storybook/recommended'],
|
extends: [
|
||||||
ignorePatterns: ['dist', '.eslintrc.cjs'],
|
"eslint:recommended",
|
||||||
parser: '@typescript-eslint/parser',
|
"plugin:@typescript-eslint/recommended",
|
||||||
plugins: ['react-refresh'],
|
"plugin:react-hooks/recommended",
|
||||||
rules: {
|
"plugin:storybook/recommended"
|
||||||
'react-refresh/only-export-components': [
|
|
||||||
'warn',
|
|
||||||
{ allowConstantExport: true },
|
|
||||||
],
|
],
|
||||||
'react-hooks/exhaustive-deps': 'off',
|
ignorePatterns: ["dist", ".eslintrc.cjs"],
|
||||||
'@typescript-eslint/no-redeclare': 'off',
|
parser: "@typescript-eslint/parser",
|
||||||
'no-labels': 'off',
|
plugins: ["react-refresh"],
|
||||||
},
|
rules: {
|
||||||
overrides: [
|
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
|
||||||
{
|
"react-hooks/exhaustive-deps": "off",
|
||||||
files: ['**/*.stories.*'],
|
"@typescript-eslint/no-redeclare": "off",
|
||||||
rules: {
|
"no-labels": "off"
|
||||||
'import/no-anonymous-default-export': 'off',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
],
|
overrides: [
|
||||||
}
|
{
|
||||||
|
files: ["**/*.stories.*"],
|
||||||
|
rules: {
|
||||||
|
"import/no-anonymous-default-export": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
110
.github/workflows/ci.yaml
vendored
110
.github/workflows/ci.yaml
vendored
|
@ -1,62 +1,60 @@
|
||||||
name: ci
|
name: ci
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v2
|
||||||
|
- uses: actions/setup-node@v2
|
||||||
|
- uses: bahmutov/npm-install@v1
|
||||||
|
- run: yarn build
|
||||||
|
- run: npx keycloakify
|
||||||
|
|
||||||
test:
|
check_if_version_upgraded:
|
||||||
runs-on: ubuntu-latest
|
name: Check if version upgrade
|
||||||
steps:
|
if: github.event_name == 'push'
|
||||||
- uses: actions/checkout@v2
|
runs-on: ubuntu-latest
|
||||||
- uses: actions/setup-node@v2
|
needs: test
|
||||||
- uses: bahmutov/npm-install@v1
|
outputs:
|
||||||
- run: yarn build
|
from_version: ${{ steps.step1.outputs.from_version }}
|
||||||
- run: npx keycloakify
|
to_version: ${{ steps.step1.outputs.to_version }}
|
||||||
|
is_upgraded_version: ${{ steps.step1.outputs.is_upgraded_version }}
|
||||||
check_if_version_upgraded:
|
steps:
|
||||||
name: Check if version upgrade
|
- uses: garronej/ts-ci@v2.1.2
|
||||||
if: github.event_name == 'push'
|
id: step1
|
||||||
runs-on: ubuntu-latest
|
with:
|
||||||
needs: test
|
action_name: is_package_json_version_upgraded
|
||||||
outputs:
|
branch: ${{ github.head_ref || github.ref }}
|
||||||
from_version: ${{ steps.step1.outputs.from_version }}
|
|
||||||
to_version: ${{ steps.step1.outputs.to_version }}
|
|
||||||
is_upgraded_version: ${{ steps.step1.outputs.is_upgraded_version }}
|
|
||||||
steps:
|
|
||||||
- uses: garronej/ts-ci@v2.1.2
|
|
||||||
id: step1
|
|
||||||
with:
|
|
||||||
action_name: is_package_json_version_upgraded
|
|
||||||
branch: ${{ github.head_ref || github.ref }}
|
|
||||||
|
|
||||||
create_github_release:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: check_if_version_upgraded
|
|
||||||
# We create a release only if the version have been upgraded and we are on a default branch
|
|
||||||
if: needs.check_if_version_upgraded.outputs.is_upgraded_version == 'true' && github.event_name == 'push'
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v2
|
|
||||||
- uses: actions/setup-node@v2
|
|
||||||
- uses: bahmutov/npm-install@v1
|
|
||||||
- run: yarn build
|
|
||||||
- run: npx keycloakify
|
|
||||||
- run: mv dist_keycloak/target/retrocompat-*.jar retrocompat-keycloak-theme.jar
|
|
||||||
- run: mv dist_keycloak/target/*.jar keycloak-theme.jar
|
|
||||||
- uses: softprops/action-gh-release@v1
|
|
||||||
with:
|
|
||||||
name: Release v${{ needs.check_if_version_upgraded.outputs.to_version }}
|
|
||||||
tag_name: v${{ needs.check_if_version_upgraded.outputs.to_version }}
|
|
||||||
target_commitish: ${{ github.head_ref || github.ref }}
|
|
||||||
generate_release_notes: true
|
|
||||||
draft: false
|
|
||||||
files: |
|
|
||||||
retrocompat-keycloak-theme.jar
|
|
||||||
keycloak-theme.jar
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
|
create_github_release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: check_if_version_upgraded
|
||||||
|
# We create a release only if the version have been upgraded and we are on a default branch
|
||||||
|
if: needs.check_if_version_upgraded.outputs.is_upgraded_version == 'true' && github.event_name == 'push'
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v2
|
||||||
|
- uses: actions/setup-node@v2
|
||||||
|
- uses: bahmutov/npm-install@v1
|
||||||
|
- run: yarn build
|
||||||
|
- run: npx keycloakify
|
||||||
|
- run: mv dist_keycloak/target/retrocompat-*.jar retrocompat-keycloak-theme.jar
|
||||||
|
- run: mv dist_keycloak/target/*.jar keycloak-theme.jar
|
||||||
|
- uses: softprops/action-gh-release@v1
|
||||||
|
with:
|
||||||
|
name: Release v${{ needs.check_if_version_upgraded.outputs.to_version }}
|
||||||
|
tag_name: v${{ needs.check_if_version_upgraded.outputs.to_version }}
|
||||||
|
target_commitish: ${{ github.head_ref || github.ref }}
|
||||||
|
generate_release_notes: true
|
||||||
|
draft: false
|
||||||
|
files: |
|
||||||
|
retrocompat-keycloak-theme.jar
|
||||||
|
keycloak-theme.jar
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
6
.prettierignore
Normal file
6
.prettierignore
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
node_modules/
|
||||||
|
/dist/
|
||||||
|
/dist_keycloak/
|
||||||
|
/public/keycloak-resources/
|
||||||
|
/.vscode/
|
||||||
|
/.yarn_home/
|
24
.prettierrc.json
Normal file
24
.prettierrc.json
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
{
|
||||||
|
"printWidth": 90,
|
||||||
|
"tabWidth": 4,
|
||||||
|
"useTabs": false,
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": false,
|
||||||
|
"trailingComma": "none",
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"arrowParens": "avoid",
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": [
|
||||||
|
"**/login/pages/*.tsx",
|
||||||
|
"**/account/pages/*.tsx",
|
||||||
|
"**/login/Template.tsx",
|
||||||
|
"**/account/Template.tsx",
|
||||||
|
"**/login/UserProfileFormFields.tsx"
|
||||||
|
],
|
||||||
|
"options": {
|
||||||
|
"printWidth": 150
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
|
@ -1,20 +1,20 @@
|
||||||
import type { StorybookConfig } from "@storybook/react-vite";
|
import type { StorybookConfig } from "@storybook/react-vite";
|
||||||
|
|
||||||
const config: StorybookConfig = {
|
const config: StorybookConfig = {
|
||||||
stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
|
stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
|
||||||
addons: [
|
addons: [
|
||||||
"@storybook/addon-links",
|
"@storybook/addon-links",
|
||||||
"@storybook/addon-essentials",
|
"@storybook/addon-essentials",
|
||||||
"@storybook/addon-onboarding",
|
"@storybook/addon-onboarding",
|
||||||
"@storybook/addon-interactions",
|
"@storybook/addon-interactions"
|
||||||
],
|
],
|
||||||
framework: {
|
framework: {
|
||||||
name: "@storybook/react-vite",
|
name: "@storybook/react-vite",
|
||||||
options: {},
|
options: {}
|
||||||
},
|
},
|
||||||
docs: {
|
docs: {
|
||||||
autodocs: "tag",
|
autodocs: "tag"
|
||||||
},
|
},
|
||||||
staticDirs: ["../public"]
|
staticDirs: ["../public"]
|
||||||
};
|
};
|
||||||
export default config;
|
export default config;
|
||||||
|
|
3
.storybook/preview-head.html
Normal file
3
.storybook/preview-head.html
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
<script>
|
||||||
|
console.log("Hello world");
|
||||||
|
</script>
|
|
@ -1,14 +1,14 @@
|
||||||
import type { Preview } from "@storybook/react";
|
import type { Preview } from "@storybook/react";
|
||||||
|
|
||||||
const preview: Preview = {
|
const preview: Preview = {
|
||||||
parameters: {
|
parameters: {
|
||||||
controls: {
|
controls: {
|
||||||
matchers: {
|
matchers: {
|
||||||
color: /(background|color)$/i,
|
color: /(background|color)$/i,
|
||||||
date: /Date$/i,
|
date: /Date$/i
|
||||||
},
|
}
|
||||||
},
|
}
|
||||||
},
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export default preview;
|
export default preview;
|
||||||
|
|
|
@ -93,7 +93,6 @@ You can enable this feature by providing multiple theme name in the Keycloakify
|
||||||
|
|
||||||
![image](https://user-images.githubusercontent.com/6702424/229296556-a69f2dc9-4653-475c-9c89-d53cf33dc05a.png)
|
![image](https://user-images.githubusercontent.com/6702424/229296556-a69f2dc9-4653-475c-9c89-d53cf33dc05a.png)
|
||||||
|
|
||||||
|
|
||||||
# The storybook
|
# The storybook
|
||||||
|
|
||||||
![image](https://github.com/keycloakify/keycloakify/assets/6702424/a18ac1ff-dcfd-4b8c-baed-dcda5aa1d762)
|
![image](https://github.com/keycloakify/keycloakify/assets/6702424/a18ac1ff-dcfd-4b8c-baed-dcda5aa1d762)
|
||||||
|
|
21
index.html
21
index.html
|
@ -1,15 +1,14 @@
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
|
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
|
||||||
</body>
|
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
98
package.json
98
package.json
|
@ -1,50 +1,52 @@
|
||||||
{
|
{
|
||||||
"name": "keycloakify-starter",
|
"name": "keycloakify-starter",
|
||||||
"version": "6.1.10",
|
"version": "6.1.10",
|
||||||
"description": "Starter for Keycloakify 10",
|
"description": "Starter for Keycloakify 10",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "git://github.com/codegouvfr/keycloakify-starter.git"
|
"url": "git://github.com/codegouvfr/keycloakify-starter.git"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
"build-keycloak-theme": "yarn build && keycloakify",
|
"build-keycloak-theme": "yarn build && keycloakify",
|
||||||
"storybook": "storybook dev -p 6006",
|
"storybook": "storybook dev -p 6006",
|
||||||
"build-storybook": "storybook build"
|
"build-storybook": "storybook build",
|
||||||
},
|
"format": "npx prettier . --write"
|
||||||
"license": "MIT",
|
},
|
||||||
"keywords": [],
|
"license": "MIT",
|
||||||
"dependencies": {
|
"keywords": [],
|
||||||
"keycloakify": "10.0.0-rc.31",
|
"dependencies": {
|
||||||
"react": "^18.2.0",
|
"keycloakify": "10.0.0-rc.33",
|
||||||
"react-dom": "^18.2.0"
|
"react": "^18.2.0",
|
||||||
},
|
"react-dom": "^18.2.0"
|
||||||
"devDependencies": {
|
},
|
||||||
"@storybook/addon-essentials": "^8.0.2",
|
"devDependencies": {
|
||||||
"@storybook/addon-interactions": "^8.0.2",
|
"@storybook/addon-essentials": "^8.0.2",
|
||||||
"@storybook/addon-links": "^8.0.2",
|
"@storybook/addon-interactions": "^8.0.2",
|
||||||
"@storybook/addon-onboarding": "^8.0.2",
|
"@storybook/addon-links": "^8.0.2",
|
||||||
"@storybook/blocks": "^8.0.2",
|
"@storybook/addon-onboarding": "^8.0.2",
|
||||||
"@storybook/react": "^8.0.2",
|
"@storybook/blocks": "^8.0.2",
|
||||||
"@storybook/react-vite": "^8.0.2",
|
"@storybook/react": "^8.0.2",
|
||||||
"@storybook/test": "^8.0.2",
|
"@storybook/react-vite": "^8.0.2",
|
||||||
"@types/react": "^18.2.43",
|
"@storybook/test": "^8.0.2",
|
||||||
"@types/react-dom": "^18.2.17",
|
"@types/react": "^18.2.43",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.14.0",
|
"@types/react-dom": "^18.2.17",
|
||||||
"@typescript-eslint/parser": "^6.14.0",
|
"@typescript-eslint/eslint-plugin": "^6.14.0",
|
||||||
"@vitejs/plugin-react": "^4.2.1",
|
"@typescript-eslint/parser": "^6.14.0",
|
||||||
"eslint": "^8.55.0",
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
"eslint-plugin-react-hooks": "^4.6.0",
|
"eslint": "^8.55.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.5",
|
"eslint-plugin-react-hooks": "^4.6.0",
|
||||||
"eslint-plugin-storybook": "^0.8.0",
|
"eslint-plugin-react-refresh": "^0.4.5",
|
||||||
"storybook": "^8.0.2",
|
"eslint-plugin-storybook": "^0.8.0",
|
||||||
"typescript": "^5.2.2",
|
"prettier": "3.3.1",
|
||||||
"vite": "^5.0.8"
|
"storybook": "^8.0.2",
|
||||||
},
|
"typescript": "^5.2.2",
|
||||||
"_comment": "See https://github.com/storybookjs/storybook/issues/22431#issuecomment-1630086092",
|
"vite": "^5.0.8"
|
||||||
"resolutions": {
|
},
|
||||||
"jackspeak": "2.1.1"
|
"_comment": "See https://github.com/storybookjs/storybook/issues/22431#issuecomment-1630086092",
|
||||||
}
|
"resolutions": {
|
||||||
|
"jackspeak": "2.1.1"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,9 +1,11 @@
|
||||||
import { Suspense, lazy } from "react";
|
import { Suspense, lazy } from "react";
|
||||||
import type { KcContext } from "./kcContext";
|
import type { PageProps } from "keycloakify/account";
|
||||||
|
import type { KcContext } from "./KcContext";
|
||||||
import { useI18n } from "./i18n";
|
import { useI18n } from "./i18n";
|
||||||
|
import Template from "keycloakify/account/Template";
|
||||||
const Fallback = lazy(() => import("keycloakify/account/Fallback"));
|
const Fallback = lazy(() => import("keycloakify/account/Fallback"));
|
||||||
const Template = lazy(() => import("./Template"));
|
|
||||||
|
const classes = {} satisfies PageProps["classes"];
|
||||||
|
|
||||||
export default function KcApp(props: { kcContext: KcContext }) {
|
export default function KcApp(props: { kcContext: KcContext }) {
|
||||||
const { kcContext } = props;
|
const { kcContext } = props;
|
||||||
|
@ -19,14 +21,17 @@ export default function KcApp(props: { kcContext: KcContext }) {
|
||||||
{(() => {
|
{(() => {
|
||||||
switch (kcContext.pageId) {
|
switch (kcContext.pageId) {
|
||||||
default:
|
default:
|
||||||
return <Fallback
|
return (
|
||||||
{...{
|
<Fallback
|
||||||
kcContext,
|
{...{
|
||||||
i18n,
|
kcContext,
|
||||||
Template,
|
i18n,
|
||||||
}}
|
classes,
|
||||||
doUseDefaultCss={true}
|
Template
|
||||||
/>
|
}}
|
||||||
|
doUseDefaultCss={true}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
})()}
|
})()}
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
|
@ -5,4 +5,7 @@ export type KcContextExtraProperties = {};
|
||||||
|
|
||||||
export type KcContextExtraPropertiesPerPage = {};
|
export type KcContextExtraPropertiesPerPage = {};
|
||||||
|
|
||||||
export type KcContext = ExtendKcContext<KcContextExtraProperties, KcContextExtraPropertiesPerPage>;
|
export type KcContext = ExtendKcContext<
|
||||||
|
KcContextExtraProperties,
|
||||||
|
KcContextExtraPropertiesPerPage
|
||||||
|
>;
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
import type { DeepPartial } from "keycloakify/tools/DeepPartial";
|
import type { DeepPartial } from "keycloakify/tools/DeepPartial";
|
||||||
import type { KcContext } from "./kcContext";
|
import type { KcContext } from "./KcContext";
|
||||||
import { createGetKcContextMock } from "keycloakify/account";
|
import { createGetKcContextMock } from "keycloakify/account";
|
||||||
import type {
|
import type {
|
||||||
KcContextExtraProperties,
|
KcContextExtraProperties,
|
||||||
KcContextExtraPropertiesPerPage
|
KcContextExtraPropertiesPerPage
|
||||||
} from "./kcContext";
|
} from "./KcContext";
|
||||||
import KcApp from "./KcApp";
|
import KcApp from "./KcApp";
|
||||||
|
|
||||||
const kcContextExtraProperties: KcContextExtraProperties = {};
|
const kcContextExtraProperties: KcContextExtraProperties = {};
|
||||||
|
@ -17,10 +17,14 @@ export const { getKcContextMock } = createGetKcContextMock({
|
||||||
overridesPerPage: {}
|
overridesPerPage: {}
|
||||||
});
|
});
|
||||||
|
|
||||||
export function createPageStory<PageId extends KcContext["pageId"]>(params: { pageId: PageId }) {
|
export function createPageStory<PageId extends KcContext["pageId"]>(params: {
|
||||||
|
pageId: PageId;
|
||||||
|
}) {
|
||||||
const { pageId } = params;
|
const { pageId } = params;
|
||||||
|
|
||||||
function PageStory(props: { kcContext?: DeepPartial<Extract<KcContext, { pageId: PageId }>> }) {
|
function PageStory(props: {
|
||||||
|
kcContext?: DeepPartial<Extract<KcContext, { pageId: PageId }>>;
|
||||||
|
}) {
|
||||||
const { kcContext: overrides } = props;
|
const { kcContext: overrides } = props;
|
||||||
|
|
||||||
const kcContextMock = getKcContextMock({
|
const kcContextMock = getKcContextMock({
|
||||||
|
@ -28,13 +32,8 @@ export function createPageStory<PageId extends KcContext["pageId"]>(params: { pa
|
||||||
overrides
|
overrides
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return <KcApp kcContext={kcContextMock} />;
|
||||||
<>
|
|
||||||
<KcApp kcContext={kcContextMock} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { PageStory };
|
return { PageStory };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,159 +0,0 @@
|
||||||
// Copy pasted from: https://github.com/keycloakify/keycloakify/blob/main/src/account/Template.tsx
|
|
||||||
|
|
||||||
import { useEffect } from "react";
|
|
||||||
import { assert } from "keycloakify/tools/assert";
|
|
||||||
import { clsx } from "keycloakify/tools/clsx";
|
|
||||||
import { useGetClassName } from "keycloakify/account/lib/useGetClassName";
|
|
||||||
import { useInsertLinkTags } from "keycloakify/tools/useInsertLinkTags";
|
|
||||||
import { useSetClassName } from "keycloakify/tools/useSetClassName";
|
|
||||||
import type { TemplateProps } from "keycloakify/account/TemplateProps";
|
|
||||||
import type { KcContext } from "./KcContext";
|
|
||||||
import type { I18n } from "./i18n";
|
|
||||||
|
|
||||||
export default function Template(props: TemplateProps<KcContext, I18n>) {
|
|
||||||
const { kcContext, i18n, doUseDefaultCss, active, classes, children } = props;
|
|
||||||
|
|
||||||
const { getClassName } = useGetClassName({ doUseDefaultCss, classes });
|
|
||||||
|
|
||||||
const { msg, msgStr, getChangeLocalUrl, labelBySupportedLanguageTag, currentLanguageTag } = i18n;
|
|
||||||
|
|
||||||
const { locale, url, features, realm, message, referrer } = kcContext;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
document.title = msgStr("accountManagementTitle");
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useSetClassName({
|
|
||||||
qualifiedName: "html",
|
|
||||||
className: getClassName("kcHtmlClass")
|
|
||||||
});
|
|
||||||
|
|
||||||
useSetClassName({
|
|
||||||
qualifiedName: "body",
|
|
||||||
className: clsx("admin-console", "user", getClassName("kcBodyClass"))
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const { currentLanguageTag } = locale ?? {};
|
|
||||||
|
|
||||||
if (currentLanguageTag === undefined) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const html = document.querySelector("html");
|
|
||||||
assert(html !== null);
|
|
||||||
html.lang = currentLanguageTag;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const { areAllStyleSheetsLoaded } = useInsertLinkTags({
|
|
||||||
componentOrHookName: "Template",
|
|
||||||
hrefs: !doUseDefaultCss
|
|
||||||
? []
|
|
||||||
: [
|
|
||||||
`${url.resourcesCommonPath}/node_modules/patternfly/dist/css/patternfly.min.css`,
|
|
||||||
`${url.resourcesCommonPath}/node_modules/patternfly/dist/css/patternfly-additions.min.css`,
|
|
||||||
`${url.resourcesPath}/css/account.css`
|
|
||||||
]
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!areAllStyleSheetsLoaded) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<header className="navbar navbar-default navbar-pf navbar-main header">
|
|
||||||
<nav className="navbar" role="navigation">
|
|
||||||
<div className="navbar-header">
|
|
||||||
<div className="container">
|
|
||||||
<h1 className="navbar-title">Keycloak</h1>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="navbar-collapse navbar-collapse-1">
|
|
||||||
<div className="container">
|
|
||||||
<ul className="nav navbar-nav navbar-utility">
|
|
||||||
{realm.internationalizationEnabled && (assert(locale !== undefined), true) && locale.supported.length > 1 && (
|
|
||||||
<li>
|
|
||||||
<div className="kc-dropdown" id="kc-locale-dropdown">
|
|
||||||
<a href="#" id="kc-current-locale-link">
|
|
||||||
{labelBySupportedLanguageTag[currentLanguageTag]}
|
|
||||||
</a>
|
|
||||||
<ul>
|
|
||||||
{locale.supported.map(({ languageTag }) => (
|
|
||||||
<li key={languageTag} className="kc-dropdown-item">
|
|
||||||
<a href={getChangeLocalUrl(languageTag)}>{labelBySupportedLanguageTag[languageTag]}</a>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
{referrer?.url && (
|
|
||||||
<li>
|
|
||||||
<a href={referrer.url} id="referrer">
|
|
||||||
{msg("backTo", referrer.name)}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
<li>
|
|
||||||
<a href={url.getLogoutUrl()}>{msg("doSignOut")}</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div className="container">
|
|
||||||
<div className="bs-sidebar col-sm-3">
|
|
||||||
<ul>
|
|
||||||
<li className={clsx(active === "account" && "active")}>
|
|
||||||
<a href={url.accountUrl}>{msg("account")}</a>
|
|
||||||
</li>
|
|
||||||
{features.passwordUpdateSupported && (
|
|
||||||
<li className={clsx(active === "password" && "active")}>
|
|
||||||
<a href={url.passwordUrl}>{msg("password")}</a>
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
<li className={clsx(active === "totp" && "active")}>
|
|
||||||
<a href={url.totpUrl}>{msg("authenticator")}</a>
|
|
||||||
</li>
|
|
||||||
{features.identityFederation && (
|
|
||||||
<li className={clsx(active === "social" && "active")}>
|
|
||||||
<a href={url.socialUrl}>{msg("federatedIdentity")}</a>
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
<li className={clsx(active === "sessions" && "active")}>
|
|
||||||
<a href={url.sessionsUrl}>{msg("sessions")}</a>
|
|
||||||
</li>
|
|
||||||
<li className={clsx(active === "applications" && "active")}>
|
|
||||||
<a href={url.applicationsUrl}>{msg("applications")}</a>
|
|
||||||
</li>
|
|
||||||
{features.log && (
|
|
||||||
<li className={clsx(active === "log" && "active")}>
|
|
||||||
<a href={url.logUrl}>{msg("log")}</a>
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
{realm.userManagedAccessAllowed && features.authorization && (
|
|
||||||
<li className={clsx(active === "authorization" && "active")}>
|
|
||||||
<a href={url.resourceUrl}>{msg("myResources")}</a>
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="col-sm-9 content-area">
|
|
||||||
{message !== undefined && (
|
|
||||||
<div className={clsx("alert", `alert-${message.type}`)}>
|
|
||||||
{message.type === "success" && <span className="pficon pficon-ok"></span>}
|
|
||||||
{message.type === "error" && <span className="pficon pficon-error-circle-o"></span>}
|
|
||||||
<span className="kc-feedback-text">{message.summary}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
19
src/account/pages/Account.stories.tsx
Normal file
19
src/account/pages/Account.stories.tsx
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
import type { Meta, StoryObj } from "@storybook/react";
|
||||||
|
import { createPageStory } from "../PageStory";
|
||||||
|
|
||||||
|
const pageId = "account.ftl";
|
||||||
|
|
||||||
|
const { PageStory } = createPageStory({ pageId });
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: `account/${pageId}`,
|
||||||
|
component: PageStory
|
||||||
|
} satisfies Meta<typeof PageStory>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
render: () => <PageStory />
|
||||||
|
};
|
29
src/account/pages/Password.stories.tsx
Normal file
29
src/account/pages/Password.stories.tsx
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
import type { Meta, StoryObj } from "@storybook/react";
|
||||||
|
import { createPageStory } from "../PageStory";
|
||||||
|
|
||||||
|
const pageId = "password.ftl";
|
||||||
|
|
||||||
|
const { PageStory } = createPageStory({ pageId });
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: `account/${pageId}`,
|
||||||
|
component: PageStory
|
||||||
|
} satisfies Meta<typeof PageStory>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
render: () => <PageStory />
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WithMessage: Story = {
|
||||||
|
render: () => (
|
||||||
|
<PageStory
|
||||||
|
kcContext={{
|
||||||
|
message: { type: "success", summary: "This is a test message" }
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
};
|
|
@ -1,11 +1,13 @@
|
||||||
import { Suspense, lazy } from "react";
|
import { Suspense, lazy } from "react";
|
||||||
|
import type { PageProps } from "keycloakify/login";
|
||||||
import type { KcContext } from "./KcContext";
|
import type { KcContext } from "./KcContext";
|
||||||
import { useI18n } from "./i18n";
|
import { useI18n } from "./i18n";
|
||||||
import { useDownloadTerms } from "keycloakify/login";
|
import { useDownloadTerms } from "keycloakify/login";
|
||||||
|
import Template from "keycloakify/login/Template";
|
||||||
const Fallback = lazy(() => import("keycloakify/login/Fallback"));
|
const Fallback = lazy(() => import("keycloakify/login/Fallback"));
|
||||||
const Template = lazy(() => import("./Template"));
|
const UserProfileFormFields = lazy(() => import("keycloakify/login/UserProfileFormFields"));
|
||||||
const UserProfileFormFields = lazy(() => import("./UserProfileFormFields"));
|
|
||||||
|
const classes = {} satisfies PageProps["classes"];
|
||||||
|
|
||||||
export default function KcApp(props: { kcContext: KcContext }) {
|
export default function KcApp(props: { kcContext: KcContext }) {
|
||||||
const { kcContext } = props;
|
const { kcContext } = props;
|
||||||
|
@ -15,12 +17,14 @@ export default function KcApp(props: { kcContext: KcContext }) {
|
||||||
useDownloadTerms({
|
useDownloadTerms({
|
||||||
kcContext,
|
kcContext,
|
||||||
downloadTermMarkdown: async ({ currentLanguageTag }) => {
|
downloadTermMarkdown: async ({ currentLanguageTag }) => {
|
||||||
|
|
||||||
const termsFileName = (() => {
|
const termsFileName = (() => {
|
||||||
switch (currentLanguageTag) {
|
switch (currentLanguageTag) {
|
||||||
case "fr": return "fr.md";
|
case "fr":
|
||||||
case "es": return "es.md";
|
return "fr.md";
|
||||||
default: return "en.md";
|
case "es":
|
||||||
|
return "es.md";
|
||||||
|
default:
|
||||||
|
return "en.md";
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
@ -28,7 +32,6 @@ export default function KcApp(props: { kcContext: KcContext }) {
|
||||||
const response = await fetch(`${import.meta.env}terms/${termsFileName}`);
|
const response = await fetch(`${import.meta.env}terms/${termsFileName}`);
|
||||||
|
|
||||||
return response.text();
|
return response.text();
|
||||||
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -41,15 +44,18 @@ export default function KcApp(props: { kcContext: KcContext }) {
|
||||||
{(() => {
|
{(() => {
|
||||||
switch (kcContext.pageId) {
|
switch (kcContext.pageId) {
|
||||||
default:
|
default:
|
||||||
return <Fallback
|
return (
|
||||||
{...{
|
<Fallback
|
||||||
kcContext,
|
{...{
|
||||||
i18n,
|
kcContext,
|
||||||
Template,
|
i18n,
|
||||||
UserProfileFormFields
|
classes,
|
||||||
}}
|
Template,
|
||||||
doUseDefaultCss={true}
|
UserProfileFormFields
|
||||||
/>
|
}}
|
||||||
|
doUseDefaultCss={true}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
})()}
|
})()}
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
|
@ -5,4 +5,7 @@ export type KcContextExtraProperties = {};
|
||||||
|
|
||||||
export type KcContextExtraPropertiesPerPage = {};
|
export type KcContextExtraPropertiesPerPage = {};
|
||||||
|
|
||||||
export type KcContext = ExtendKcContext<KcContextExtraProperties, KcContextExtraPropertiesPerPage>;
|
export type KcContext = ExtendKcContext<
|
||||||
|
KcContextExtraProperties,
|
||||||
|
KcContextExtraPropertiesPerPage
|
||||||
|
>;
|
||||||
|
|
|
@ -1,11 +1,11 @@
|
||||||
import type { DeepPartial } from "keycloakify/tools/DeepPartial";
|
import type { DeepPartial } from "keycloakify/tools/DeepPartial";
|
||||||
import type { KcContext } from "./kcContext";
|
import type { KcContext } from "./KcContext";
|
||||||
import KcApp from "./KcApp";
|
import KcApp from "./KcApp";
|
||||||
import { createGetKcContextMock } from "keycloakify/login";
|
import { createGetKcContextMock } from "keycloakify/login";
|
||||||
import type {
|
import type {
|
||||||
KcContextExtraProperties,
|
KcContextExtraProperties,
|
||||||
KcContextExtraPropertiesPerPage
|
KcContextExtraPropertiesPerPage
|
||||||
} from "./kcContext";
|
} from "./KcContext";
|
||||||
|
|
||||||
const kcContextExtraProperties: KcContextExtraProperties = {};
|
const kcContextExtraProperties: KcContextExtraProperties = {};
|
||||||
const kcContextExtraPropertiesPerPage: KcContextExtraPropertiesPerPage = {};
|
const kcContextExtraPropertiesPerPage: KcContextExtraPropertiesPerPage = {};
|
||||||
|
@ -17,10 +17,14 @@ export const { getKcContextMock } = createGetKcContextMock({
|
||||||
overridesPerPage: {}
|
overridesPerPage: {}
|
||||||
});
|
});
|
||||||
|
|
||||||
export function createPageStory<PageId extends KcContext["pageId"]>(params: { pageId: PageId }) {
|
export function createPageStory<PageId extends KcContext["pageId"]>(params: {
|
||||||
|
pageId: PageId;
|
||||||
|
}) {
|
||||||
const { pageId } = params;
|
const { pageId } = params;
|
||||||
|
|
||||||
function PageStory(props: { kcContext?: DeepPartial<Extract<KcContext, { pageId: PageId }>> }) {
|
function PageStory(props: {
|
||||||
|
kcContext?: DeepPartial<Extract<KcContext, { pageId: PageId }>>;
|
||||||
|
}) {
|
||||||
const { kcContext: overrides } = props;
|
const { kcContext: overrides } = props;
|
||||||
|
|
||||||
const kcContextMock = getKcContextMock({
|
const kcContextMock = getKcContextMock({
|
||||||
|
@ -37,4 +41,3 @@ export function createPageStory<PageId extends KcContext["pageId"]>(params: { pa
|
||||||
|
|
||||||
return { PageStory };
|
return { PageStory };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,278 +0,0 @@
|
||||||
// Copy pasted from: https://github.com/keycloakify/keycloakify/blob/main/src/login/Template.tsx
|
|
||||||
|
|
||||||
import { useEffect } from "react";
|
|
||||||
import { assert } from "keycloakify/tools/assert";
|
|
||||||
import { clsx } from "keycloakify/tools/clsx";
|
|
||||||
import type { TemplateProps } from "keycloakify/login/TemplateProps";
|
|
||||||
import { useGetClassName } from "keycloakify/login/lib/useGetClassName";
|
|
||||||
import { useInsertScriptTags } from "keycloakify/tools/useInsertScriptTags";
|
|
||||||
import { useInsertLinkTags } from "keycloakify/tools/useInsertLinkTags";
|
|
||||||
import { useSetClassName } from "keycloakify/tools/useSetClassName";
|
|
||||||
import type { KcContext } from "./KcContext";
|
|
||||||
import type { I18n } from "./i18n";
|
|
||||||
|
|
||||||
export default function Template(props: TemplateProps<KcContext, I18n>) {
|
|
||||||
const {
|
|
||||||
displayInfo = false,
|
|
||||||
displayMessage = true,
|
|
||||||
displayRequiredFields = false,
|
|
||||||
headerNode,
|
|
||||||
showUsernameNode = null,
|
|
||||||
socialProvidersNode = null,
|
|
||||||
infoNode = null,
|
|
||||||
documentTitle,
|
|
||||||
bodyClassName,
|
|
||||||
kcContext,
|
|
||||||
i18n,
|
|
||||||
doUseDefaultCss,
|
|
||||||
classes,
|
|
||||||
children
|
|
||||||
} = props;
|
|
||||||
|
|
||||||
const { getClassName } = useGetClassName({ doUseDefaultCss, classes });
|
|
||||||
|
|
||||||
const { msg, msgStr, getChangeLocalUrl, labelBySupportedLanguageTag, currentLanguageTag } = i18n;
|
|
||||||
|
|
||||||
const { realm, locale, auth, url, message, isAppInitiatedAction, authenticationSession, scripts } = kcContext;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
document.title = documentTitle ?? msgStr("loginTitle", kcContext.realm.displayName);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useSetClassName({
|
|
||||||
qualifiedName: "html",
|
|
||||||
className: getClassName("kcHtmlClass")
|
|
||||||
});
|
|
||||||
|
|
||||||
useSetClassName({
|
|
||||||
qualifiedName: "body",
|
|
||||||
className: bodyClassName ?? getClassName("kcBodyClass")
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const { currentLanguageTag } = locale ?? {};
|
|
||||||
|
|
||||||
if (currentLanguageTag === undefined) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const html = document.querySelector("html");
|
|
||||||
assert(html !== null);
|
|
||||||
html.lang = currentLanguageTag;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const { areAllStyleSheetsLoaded } = useInsertLinkTags({
|
|
||||||
componentOrHookName: "Template",
|
|
||||||
hrefs: !doUseDefaultCss
|
|
||||||
? []
|
|
||||||
: [
|
|
||||||
`${url.resourcesCommonPath}/node_modules/@patternfly/patternfly/patternfly.min.css`,
|
|
||||||
`${url.resourcesCommonPath}/node_modules/patternfly/dist/css/patternfly.min.css`,
|
|
||||||
`${url.resourcesCommonPath}/node_modules/patternfly/dist/css/patternfly-additions.min.css`,
|
|
||||||
`${url.resourcesCommonPath}/lib/pficon/pficon.css`,
|
|
||||||
`${url.resourcesPath}/css/login.css`
|
|
||||||
]
|
|
||||||
});
|
|
||||||
|
|
||||||
const { insertScriptTags } = useInsertScriptTags({
|
|
||||||
componentOrHookName: "Template",
|
|
||||||
scriptTags: [
|
|
||||||
{
|
|
||||||
type: "module",
|
|
||||||
src: `${url.resourcesPath}/js/menu-button-links.js`
|
|
||||||
},
|
|
||||||
...(authenticationSession === undefined
|
|
||||||
? []
|
|
||||||
: [
|
|
||||||
{
|
|
||||||
type: "module",
|
|
||||||
textContent: [
|
|
||||||
`import { checkCookiesAndSetTimer } from "${url.resourcesPath}/js/authChecker.js";`,
|
|
||||||
``,
|
|
||||||
`checkCookiesAndSetTimer(`,
|
|
||||||
` "${authenticationSession.authSessionId}",`,
|
|
||||||
` "${authenticationSession.tabId}",`,
|
|
||||||
` "${url.ssoLoginInOtherTabsUrl}"`,
|
|
||||||
`);`
|
|
||||||
].join("\n")
|
|
||||||
} as const
|
|
||||||
]),
|
|
||||||
...scripts.map(
|
|
||||||
script =>
|
|
||||||
({
|
|
||||||
type: "text/javascript",
|
|
||||||
src: script
|
|
||||||
}) as const
|
|
||||||
)
|
|
||||||
]
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (areAllStyleSheetsLoaded) {
|
|
||||||
insertScriptTags();
|
|
||||||
}
|
|
||||||
}, [areAllStyleSheetsLoaded]);
|
|
||||||
|
|
||||||
if (!areAllStyleSheetsLoaded) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={getClassName("kcLoginClass")}>
|
|
||||||
<div id="kc-header" className={getClassName("kcHeaderClass")}>
|
|
||||||
<div id="kc-header-wrapper" className={getClassName("kcHeaderWrapperClass")}>
|
|
||||||
{msg("loginTitleHtml", realm.displayNameHtml)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={getClassName("kcFormCardClass")}>
|
|
||||||
<header className={getClassName("kcFormHeaderClass")}>
|
|
||||||
{realm.internationalizationEnabled && (assert(locale !== undefined), locale.supported.length > 1) && (
|
|
||||||
<div className={getClassName("kcLocaleMainClass")} id="kc-locale">
|
|
||||||
<div id="kc-locale-wrapper" className={getClassName("kcLocaleWrapperClass")}>
|
|
||||||
<div id="kc-locale-dropdown" className={clsx("menu-button-links", getClassName("kcLocaleDropDownClass"))}>
|
|
||||||
<button
|
|
||||||
tabIndex={1}
|
|
||||||
id="kc-current-locale-link"
|
|
||||||
aria-label={msgStr("languages")}
|
|
||||||
aria-haspopup="true"
|
|
||||||
aria-expanded="false"
|
|
||||||
aria-controls="language-switch1"
|
|
||||||
>
|
|
||||||
{labelBySupportedLanguageTag[currentLanguageTag]}
|
|
||||||
</button>
|
|
||||||
<ul
|
|
||||||
role="menu"
|
|
||||||
tabIndex={-1}
|
|
||||||
aria-labelledby="kc-current-locale-link"
|
|
||||||
aria-activedescendant=""
|
|
||||||
id="language-switch1"
|
|
||||||
className={getClassName("kcLocaleListClass")}
|
|
||||||
>
|
|
||||||
{locale.supported.map(({ languageTag }, i) => (
|
|
||||||
<li key={languageTag} className={getClassName("kcLocaleListItemClass")} role="none">
|
|
||||||
<a
|
|
||||||
role="menuitem"
|
|
||||||
id={`language-${i + 1}`}
|
|
||||||
className={getClassName("kcLocaleItemClass")}
|
|
||||||
href={getChangeLocalUrl(languageTag)}
|
|
||||||
>
|
|
||||||
{labelBySupportedLanguageTag[languageTag]}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!(auth !== undefined && auth.showUsername && !auth.showResetCredentials) ? (
|
|
||||||
displayRequiredFields ? (
|
|
||||||
<div className={getClassName("kcContentWrapperClass")}>
|
|
||||||
<div className={clsx(getClassName("kcLabelWrapperClass"), "subtitle")}>
|
|
||||||
<span className="subtitle">
|
|
||||||
<span className="required">*</span>
|
|
||||||
{msg("requiredFields")}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="col-md-10">
|
|
||||||
<h1 id="kc-page-title">{headerNode}</h1>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<h1 id="kc-page-title">{headerNode}</h1>
|
|
||||||
)
|
|
||||||
) : displayRequiredFields ? (
|
|
||||||
<div className={getClassName("kcContentWrapperClass")}>
|
|
||||||
<div className={clsx(getClassName("kcLabelWrapperClass"), "subtitle")}>
|
|
||||||
<span className="subtitle">
|
|
||||||
<span className="required">*</span> {msg("requiredFields")}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="col-md-10">
|
|
||||||
{showUsernameNode}
|
|
||||||
<div id="kc-username" className={getClassName("kcFormGroupClass")}>
|
|
||||||
<label id="kc-attempted-username">{auth.attemptedUsername}</label>
|
|
||||||
<a id="reset-login" href={url.loginRestartFlowUrl} aria-label={msgStr("restartLoginTooltip")}>
|
|
||||||
<div className="kc-login-tooltip">
|
|
||||||
<i className={getClassName("kcResetFlowIcon")}></i>
|
|
||||||
<span className="kc-tooltip-text">{msg("restartLoginTooltip")}</span>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{showUsernameNode}
|
|
||||||
<div id="kc-username" className={getClassName("kcFormGroupClass")}>
|
|
||||||
<label id="kc-attempted-username">{auth.attemptedUsername}</label>
|
|
||||||
<a id="reset-login" href={url.loginRestartFlowUrl} aria-label={msgStr("restartLoginTooltip")}>
|
|
||||||
<div className="kc-login-tooltip">
|
|
||||||
<i className={getClassName("kcResetFlowIcon")}></i>
|
|
||||||
<span className="kc-tooltip-text">{msg("restartLoginTooltip")}</span>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</header>
|
|
||||||
<div id="kc-content">
|
|
||||||
<div id="kc-content-wrapper">
|
|
||||||
{/* App-initiated actions should not see warning messages about the need to complete the action during login. */}
|
|
||||||
{displayMessage && message !== undefined && (message.type !== "warning" || !isAppInitiatedAction) && (
|
|
||||||
<div
|
|
||||||
className={clsx(
|
|
||||||
`alert-${message.type}`,
|
|
||||||
getClassName("kcAlertClass"),
|
|
||||||
`pf-m-${message?.type === "error" ? "danger" : message.type}`
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="pf-c-alert__icon">
|
|
||||||
{message.type === "success" && <span className={getClassName("kcFeedbackSuccessIcon")}></span>}
|
|
||||||
{message.type === "warning" && <span className={getClassName("kcFeedbackWarningIcon")}></span>}
|
|
||||||
{message.type === "error" && <span className={getClassName("kcFeedbackErrorIcon")}></span>}
|
|
||||||
{message.type === "info" && <span className={getClassName("kcFeedbackInfoIcon")}></span>}
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
className={getClassName("kcAlertTitleClass")}
|
|
||||||
dangerouslySetInnerHTML={{
|
|
||||||
__html: message.summary
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{children}
|
|
||||||
{auth !== undefined && auth.showTryAnotherWayLink && (
|
|
||||||
<form id="kc-select-try-another-way-form" action={url.loginAction} method="post">
|
|
||||||
<div className={getClassName("kcFormGroupClass")}>
|
|
||||||
<div className={getClassName("kcFormGroupClass")}>
|
|
||||||
<input type="hidden" name="tryAnotherWay" value="on" />
|
|
||||||
<a
|
|
||||||
href="#"
|
|
||||||
id="try-another-way"
|
|
||||||
onClick={() => {
|
|
||||||
document.forms["kc-select-try-another-way-form" as never].submit();
|
|
||||||
return false;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{msg("doTryAnotherWay")}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
{socialProvidersNode}
|
|
||||||
{displayInfo && (
|
|
||||||
<div id="kc-info" className={getClassName("kcSignUpClass")}>
|
|
||||||
<div id="kc-info-wrapper" className={getClassName("kcInfoAreaWrapperClass")}>
|
|
||||||
{infoNode}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -1,699 +0,0 @@
|
||||||
// Copy pasted from: https://github.com/keycloakify/keycloakify/blob/main/src/login/UserProfileFormFields.tsx
|
|
||||||
|
|
||||||
import { useEffect, useReducer, Fragment } from "react";
|
|
||||||
import { assert } from "tsafe/assert";
|
|
||||||
import type { ClassKey } from "keycloakify/login/TemplateProps";
|
|
||||||
import {
|
|
||||||
useUserProfileForm,
|
|
||||||
getButtonToDisplayForMultivaluedAttributeField,
|
|
||||||
type KcContextLike,
|
|
||||||
type FormAction,
|
|
||||||
type FormFieldError
|
|
||||||
} from "keycloakify/login/lib/useUserProfileForm";
|
|
||||||
import type { Attribute } from "keycloakify/login/KcContext";
|
|
||||||
import type { I18n } from "./i18n";
|
|
||||||
|
|
||||||
export type UserProfileFormFieldsProps = {
|
|
||||||
kcContext: KcContextLike;
|
|
||||||
i18n: I18n;
|
|
||||||
getClassName: (classKey: ClassKey) => string;
|
|
||||||
onIsFormSubmittableValueChange: (isFormSubmittable: boolean) => void;
|
|
||||||
BeforeField?: (props: BeforeAfterFieldProps) => JSX.Element | null;
|
|
||||||
AfterField?: (props: BeforeAfterFieldProps) => JSX.Element | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
type BeforeAfterFieldProps = {
|
|
||||||
attribute: Attribute;
|
|
||||||
dispatchFormAction: React.Dispatch<FormAction>;
|
|
||||||
displayableErrors: FormFieldError[];
|
|
||||||
i18n: I18n;
|
|
||||||
valueOrValues: string | string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
// NOTE: Enabled by default but it's a UX best practice to set it to false.
|
|
||||||
const doMakeUserConfirmPassword = true;
|
|
||||||
|
|
||||||
export default function UserProfileFormFields(props: UserProfileFormFieldsProps) {
|
|
||||||
const { kcContext, onIsFormSubmittableValueChange, i18n, getClassName, BeforeField, AfterField } = props;
|
|
||||||
|
|
||||||
const { advancedMsg } = i18n;
|
|
||||||
|
|
||||||
const {
|
|
||||||
formState: { formFieldStates, isFormSubmittable },
|
|
||||||
dispatchFormAction
|
|
||||||
} = useUserProfileForm({
|
|
||||||
kcContext,
|
|
||||||
i18n,
|
|
||||||
doMakeUserConfirmPassword
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
onIsFormSubmittableValueChange(isFormSubmittable);
|
|
||||||
}, [isFormSubmittable]);
|
|
||||||
|
|
||||||
const groupNameRef = { current: "" };
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{formFieldStates.map(({ attribute, displayableErrors, valueOrValues }) => {
|
|
||||||
return (
|
|
||||||
<Fragment key={attribute.name}>
|
|
||||||
<GroupLabel attribute={attribute} getClassName={getClassName} i18n={i18n} groupNameRef={groupNameRef} />
|
|
||||||
{BeforeField !== undefined && (
|
|
||||||
<BeforeField
|
|
||||||
attribute={attribute}
|
|
||||||
dispatchFormAction={dispatchFormAction}
|
|
||||||
displayableErrors={displayableErrors}
|
|
||||||
i18n={i18n}
|
|
||||||
valueOrValues={valueOrValues}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div
|
|
||||||
className={getClassName("kcFormGroupClass")}
|
|
||||||
style={{
|
|
||||||
display: attribute.name === "password-confirm" && !doMakeUserConfirmPassword ? "none" : undefined
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className={getClassName("kcLabelWrapperClass")}>
|
|
||||||
<label htmlFor={attribute.name} className={getClassName("kcLabelClass")}>
|
|
||||||
{advancedMsg(attribute.displayName ?? "")}
|
|
||||||
</label>
|
|
||||||
{attribute.required && <>*</>}
|
|
||||||
</div>
|
|
||||||
<div className={getClassName("kcInputWrapperClass")}>
|
|
||||||
{attribute.annotations.inputHelperTextBefore !== undefined && (
|
|
||||||
<div
|
|
||||||
className={getClassName("kcInputHelperTextBeforeClass")}
|
|
||||||
id={`form-help-text-before-${attribute.name}`}
|
|
||||||
aria-live="polite"
|
|
||||||
>
|
|
||||||
{advancedMsg(attribute.annotations.inputHelperTextBefore)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<InputFiledByType
|
|
||||||
attribute={attribute}
|
|
||||||
valueOrValues={valueOrValues}
|
|
||||||
displayableErrors={displayableErrors}
|
|
||||||
formValidationDispatch={dispatchFormAction}
|
|
||||||
getClassName={getClassName}
|
|
||||||
i18n={i18n}
|
|
||||||
/>
|
|
||||||
<FieldErrors
|
|
||||||
attribute={attribute}
|
|
||||||
getClassName={getClassName}
|
|
||||||
displayableErrors={displayableErrors}
|
|
||||||
fieldIndex={undefined}
|
|
||||||
/>
|
|
||||||
{attribute.annotations.inputHelperTextAfter !== undefined && (
|
|
||||||
<div
|
|
||||||
className={getClassName("kcInputHelperTextAfterClass")}
|
|
||||||
id={`form-help-text-after-${attribute.name}`}
|
|
||||||
aria-live="polite"
|
|
||||||
>
|
|
||||||
{advancedMsg(attribute.annotations.inputHelperTextAfter)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{AfterField !== undefined && (
|
|
||||||
<AfterField
|
|
||||||
attribute={attribute}
|
|
||||||
dispatchFormAction={dispatchFormAction}
|
|
||||||
displayableErrors={displayableErrors}
|
|
||||||
i18n={i18n}
|
|
||||||
valueOrValues={valueOrValues}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{/* NOTE: Downloading of html5DataAnnotations scripts is done in the useUserProfileForm hook */}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Fragment>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function GroupLabel(props: {
|
|
||||||
attribute: Attribute;
|
|
||||||
getClassName: UserProfileFormFieldsProps["getClassName"];
|
|
||||||
i18n: I18n;
|
|
||||||
groupNameRef: {
|
|
||||||
current: string;
|
|
||||||
};
|
|
||||||
}) {
|
|
||||||
const { attribute, getClassName, i18n, groupNameRef } = props;
|
|
||||||
|
|
||||||
const { advancedMsg } = i18n;
|
|
||||||
|
|
||||||
if (attribute.group?.name !== groupNameRef.current) {
|
|
||||||
groupNameRef.current = attribute.group?.name ?? "";
|
|
||||||
|
|
||||||
if (groupNameRef.current !== "") {
|
|
||||||
assert(attribute.group !== undefined);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={getClassName("kcFormGroupClass")}
|
|
||||||
{...Object.fromEntries(Object.entries(attribute.group.html5DataAnnotations).map(([key, value]) => [`data-${key}`, value]))}
|
|
||||||
>
|
|
||||||
{(() => {
|
|
||||||
const groupDisplayHeader = attribute.group.displayHeader ?? "";
|
|
||||||
const groupHeaderText = groupDisplayHeader !== "" ? advancedMsg(groupDisplayHeader) : attribute.group.name;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={getClassName("kcContentWrapperClass")}>
|
|
||||||
<label id={`header-${attribute.group.name}`} className={getClassName("kcFormGroupHeader")}>
|
|
||||||
{groupHeaderText}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
{(() => {
|
|
||||||
const groupDisplayDescription = attribute.group.displayDescription ?? "";
|
|
||||||
|
|
||||||
if (groupDisplayDescription !== "") {
|
|
||||||
const groupDescriptionText = advancedMsg(groupDisplayDescription);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={getClassName("kcLabelWrapperClass")}>
|
|
||||||
<label id={`description-${attribute.group.name}`} className={getClassName("kcLabelClass")}>
|
|
||||||
{groupDescriptionText}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function FieldErrors(props: {
|
|
||||||
attribute: Attribute;
|
|
||||||
getClassName: UserProfileFormFieldsProps["getClassName"];
|
|
||||||
displayableErrors: FormFieldError[];
|
|
||||||
fieldIndex: number | undefined;
|
|
||||||
}) {
|
|
||||||
const { attribute, getClassName, fieldIndex } = props;
|
|
||||||
|
|
||||||
const displayableErrors = props.displayableErrors.filter(error => error.fieldIndex === fieldIndex);
|
|
||||||
|
|
||||||
if (displayableErrors.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
id={`input-error-${attribute.name}${fieldIndex === undefined ? "" : `-${fieldIndex}`}`}
|
|
||||||
className={getClassName("kcInputErrorMessageClass")}
|
|
||||||
aria-live="polite"
|
|
||||||
>
|
|
||||||
{displayableErrors
|
|
||||||
.filter(error => error.fieldIndex === fieldIndex)
|
|
||||||
.map(({ errorMessage }, i, arr) => (
|
|
||||||
<Fragment key={i}>
|
|
||||||
<span key={i}>{errorMessage}</span>
|
|
||||||
{arr.length - 1 !== i && <br />}
|
|
||||||
</Fragment>
|
|
||||||
))}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type InputFiledByTypeProps = {
|
|
||||||
attribute: Attribute;
|
|
||||||
valueOrValues: string | string[];
|
|
||||||
displayableErrors: FormFieldError[];
|
|
||||||
formValidationDispatch: React.Dispatch<FormAction>;
|
|
||||||
getClassName: UserProfileFormFieldsProps["getClassName"];
|
|
||||||
i18n: I18n;
|
|
||||||
};
|
|
||||||
|
|
||||||
function InputFiledByType(props: InputFiledByTypeProps) {
|
|
||||||
const { attribute, valueOrValues } = props;
|
|
||||||
|
|
||||||
switch (attribute.annotations.inputType) {
|
|
||||||
case "textarea":
|
|
||||||
return <TextareaTag {...props} />;
|
|
||||||
case "select":
|
|
||||||
case "multiselect":
|
|
||||||
return <SelectTag {...props} />;
|
|
||||||
case "select-radiobuttons":
|
|
||||||
case "multiselect-checkboxes":
|
|
||||||
return <InputTagSelects {...props} />;
|
|
||||||
default: {
|
|
||||||
if (valueOrValues instanceof Array) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{valueOrValues.map((...[, i]) => (
|
|
||||||
<InputTag key={i} {...props} fieldIndex={i} />
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const inputNode = <InputTag {...props} fieldIndex={undefined} />;
|
|
||||||
|
|
||||||
if (attribute.name === "password" || attribute.name === "password-confirm") {
|
|
||||||
return (
|
|
||||||
<PasswordWrapper getClassName={props.getClassName} i18n={props.i18n} passwordInputId={attribute.name}>
|
|
||||||
{inputNode}
|
|
||||||
</PasswordWrapper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return inputNode;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function PasswordWrapper(props: { getClassName: (classKey: ClassKey) => string; i18n: I18n; passwordInputId: string; children: JSX.Element }) {
|
|
||||||
const { getClassName, i18n, passwordInputId, children } = props;
|
|
||||||
|
|
||||||
const { msgStr } = i18n;
|
|
||||||
|
|
||||||
const [isPasswordRevealed, toggleIsPasswordRevealed] = useReducer((isPasswordRevealed: boolean) => !isPasswordRevealed, false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const passwordInputElement = document.getElementById(passwordInputId);
|
|
||||||
|
|
||||||
assert(passwordInputElement instanceof HTMLInputElement);
|
|
||||||
|
|
||||||
passwordInputElement.type = isPasswordRevealed ? "text" : "password";
|
|
||||||
}, [isPasswordRevealed]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={getClassName("kcInputGroup")}>
|
|
||||||
{children}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={getClassName("kcFormPasswordVisibilityButtonClass")}
|
|
||||||
aria-label={msgStr(isPasswordRevealed ? "hidePassword" : "showPassword")}
|
|
||||||
aria-controls={passwordInputId}
|
|
||||||
onClick={toggleIsPasswordRevealed}
|
|
||||||
>
|
|
||||||
<i
|
|
||||||
className={getClassName(isPasswordRevealed ? "kcFormPasswordVisibilityIconHide" : "kcFormPasswordVisibilityIconShow")}
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function InputTag(props: InputFiledByTypeProps & { fieldIndex: number | undefined }) {
|
|
||||||
const { attribute, fieldIndex, getClassName, formValidationDispatch, valueOrValues, i18n, displayableErrors } = props;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<input
|
|
||||||
type={(() => {
|
|
||||||
const { inputType } = attribute.annotations;
|
|
||||||
|
|
||||||
if (inputType?.startsWith("html5-")) {
|
|
||||||
return inputType.slice(6);
|
|
||||||
}
|
|
||||||
|
|
||||||
return inputType ?? "text";
|
|
||||||
})()}
|
|
||||||
id={attribute.name}
|
|
||||||
name={attribute.name}
|
|
||||||
value={(() => {
|
|
||||||
if (fieldIndex !== undefined) {
|
|
||||||
assert(valueOrValues instanceof Array);
|
|
||||||
return valueOrValues[fieldIndex];
|
|
||||||
}
|
|
||||||
|
|
||||||
assert(typeof valueOrValues === "string");
|
|
||||||
|
|
||||||
return valueOrValues;
|
|
||||||
})()}
|
|
||||||
className={getClassName("kcInputClass")}
|
|
||||||
aria-invalid={displayableErrors.find(error => error.fieldIndex === fieldIndex) !== undefined}
|
|
||||||
disabled={attribute.readOnly}
|
|
||||||
autoComplete={attribute.autocomplete}
|
|
||||||
placeholder={attribute.annotations.inputTypePlaceholder}
|
|
||||||
pattern={attribute.annotations.inputTypePattern}
|
|
||||||
size={attribute.annotations.inputTypeSize === undefined ? undefined : parseInt(`${attribute.annotations.inputTypeSize}`)}
|
|
||||||
maxLength={
|
|
||||||
attribute.annotations.inputTypeMaxlength === undefined ? undefined : parseInt(`${attribute.annotations.inputTypeMaxlength}`)
|
|
||||||
}
|
|
||||||
minLength={
|
|
||||||
attribute.annotations.inputTypeMinlength === undefined ? undefined : parseInt(`${attribute.annotations.inputTypeMinlength}`)
|
|
||||||
}
|
|
||||||
max={attribute.annotations.inputTypeMax}
|
|
||||||
min={attribute.annotations.inputTypeMin}
|
|
||||||
step={attribute.annotations.inputTypeStep}
|
|
||||||
{...Object.fromEntries(Object.entries(attribute.html5DataAnnotations ?? {}).map(([key, value]) => [`data-${key}`, value]))}
|
|
||||||
onChange={event =>
|
|
||||||
formValidationDispatch({
|
|
||||||
action: "update",
|
|
||||||
name: attribute.name,
|
|
||||||
valueOrValues: (() => {
|
|
||||||
if (fieldIndex !== undefined) {
|
|
||||||
assert(valueOrValues instanceof Array);
|
|
||||||
|
|
||||||
return valueOrValues.map((value, i) => {
|
|
||||||
if (i === fieldIndex) {
|
|
||||||
return event.target.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
return value;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return event.target.value;
|
|
||||||
})()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
onBlur={() =>
|
|
||||||
props.formValidationDispatch({
|
|
||||||
action: "focus lost",
|
|
||||||
name: attribute.name,
|
|
||||||
fieldIndex: fieldIndex
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
{(() => {
|
|
||||||
if (fieldIndex === undefined) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
assert(valueOrValues instanceof Array);
|
|
||||||
|
|
||||||
const values = valueOrValues;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<FieldErrors
|
|
||||||
attribute={attribute}
|
|
||||||
getClassName={getClassName}
|
|
||||||
displayableErrors={displayableErrors}
|
|
||||||
fieldIndex={fieldIndex}
|
|
||||||
/>
|
|
||||||
<AddRemoveButtonsMultiValuedAttribute
|
|
||||||
attribute={attribute}
|
|
||||||
values={values}
|
|
||||||
fieldIndex={fieldIndex}
|
|
||||||
dispatchFormAction={formValidationDispatch}
|
|
||||||
i18n={i18n}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function AddRemoveButtonsMultiValuedAttribute(props: {
|
|
||||||
attribute: Attribute;
|
|
||||||
values: string[];
|
|
||||||
fieldIndex: number;
|
|
||||||
dispatchFormAction: React.Dispatch<Extract<FormAction, { action: "update" }>>;
|
|
||||||
i18n: I18n;
|
|
||||||
}) {
|
|
||||||
const { attribute, values, fieldIndex, dispatchFormAction, i18n } = props;
|
|
||||||
|
|
||||||
const { msg } = i18n;
|
|
||||||
|
|
||||||
const { hasAdd, hasRemove } = getButtonToDisplayForMultivaluedAttributeField({ attribute, values, fieldIndex });
|
|
||||||
|
|
||||||
const idPostfix = `-${attribute.name}-${fieldIndex + 1}`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{hasRemove && (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
id={`kc-remove${idPostfix}`}
|
|
||||||
type="button"
|
|
||||||
className="pf-c-button pf-m-inline pf-m-link"
|
|
||||||
onClick={() =>
|
|
||||||
dispatchFormAction({
|
|
||||||
action: "update",
|
|
||||||
name: attribute.name,
|
|
||||||
valueOrValues: values.filter((_, i) => i !== fieldIndex)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{msg("remove")}
|
|
||||||
</button>
|
|
||||||
{hasAdd ? <> | </> : null}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{hasAdd && (
|
|
||||||
<button
|
|
||||||
id={`kc-add${idPostfix}`}
|
|
||||||
type="button"
|
|
||||||
className="pf-c-button pf-m-inline pf-m-link"
|
|
||||||
onClick={() =>
|
|
||||||
dispatchFormAction({
|
|
||||||
action: "update",
|
|
||||||
name: attribute.name,
|
|
||||||
valueOrValues: [...values, ""]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{msg("addValue")}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function InputTagSelects(props: InputFiledByTypeProps) {
|
|
||||||
const { attribute, formValidationDispatch, getClassName, valueOrValues } = props;
|
|
||||||
|
|
||||||
const { advancedMsg } = props.i18n;
|
|
||||||
|
|
||||||
const { classDiv, classInput, classLabel, inputType } = (() => {
|
|
||||||
const { inputType } = attribute.annotations;
|
|
||||||
|
|
||||||
assert(inputType === "select-radiobuttons" || inputType === "multiselect-checkboxes");
|
|
||||||
|
|
||||||
switch (inputType) {
|
|
||||||
case "select-radiobuttons":
|
|
||||||
return {
|
|
||||||
inputType: "radio",
|
|
||||||
classDiv: getClassName("kcInputClassRadio"),
|
|
||||||
classInput: getClassName("kcInputClassRadioInput"),
|
|
||||||
classLabel: getClassName("kcInputClassRadioLabel")
|
|
||||||
};
|
|
||||||
case "multiselect-checkboxes":
|
|
||||||
return {
|
|
||||||
inputType: "checkbox",
|
|
||||||
classDiv: getClassName("kcInputClassCheckbox"),
|
|
||||||
classInput: getClassName("kcInputClassCheckboxInput"),
|
|
||||||
classLabel: getClassName("kcInputClassCheckboxLabel")
|
|
||||||
};
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
const options = (() => {
|
|
||||||
walk: {
|
|
||||||
const { inputOptionsFromValidation } = attribute.annotations;
|
|
||||||
|
|
||||||
if (inputOptionsFromValidation === undefined) {
|
|
||||||
break walk;
|
|
||||||
}
|
|
||||||
|
|
||||||
const validator = (attribute.validators as Record<string, { options?: string[] }>)[inputOptionsFromValidation];
|
|
||||||
|
|
||||||
if (validator === undefined) {
|
|
||||||
break walk;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (validator.options === undefined) {
|
|
||||||
break walk;
|
|
||||||
}
|
|
||||||
|
|
||||||
return validator.options;
|
|
||||||
}
|
|
||||||
|
|
||||||
return attribute.validators.options?.options ?? [];
|
|
||||||
})();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{options.map(option => (
|
|
||||||
<div key={option} className={classDiv}>
|
|
||||||
<input
|
|
||||||
type={inputType}
|
|
||||||
id={`${attribute.name}-${option}`}
|
|
||||||
name={attribute.name}
|
|
||||||
value={option}
|
|
||||||
className={classInput}
|
|
||||||
aria-invalid={props.displayableErrors.length !== 0}
|
|
||||||
disabled={attribute.readOnly}
|
|
||||||
checked={valueOrValues instanceof Array ? valueOrValues.includes(option) : valueOrValues === option}
|
|
||||||
onChange={event =>
|
|
||||||
formValidationDispatch({
|
|
||||||
action: "update",
|
|
||||||
name: attribute.name,
|
|
||||||
valueOrValues: (() => {
|
|
||||||
const isChecked = event.target.checked;
|
|
||||||
|
|
||||||
if (valueOrValues instanceof Array) {
|
|
||||||
const newValues = [...valueOrValues];
|
|
||||||
|
|
||||||
if (isChecked) {
|
|
||||||
newValues.push(option);
|
|
||||||
} else {
|
|
||||||
newValues.splice(newValues.indexOf(option), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
return newValues;
|
|
||||||
}
|
|
||||||
|
|
||||||
return event.target.checked ? option : "";
|
|
||||||
})()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
onBlur={() =>
|
|
||||||
formValidationDispatch({
|
|
||||||
action: "focus lost",
|
|
||||||
name: attribute.name,
|
|
||||||
fieldIndex: undefined
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<label
|
|
||||||
htmlFor={`${attribute.name}-${option}`}
|
|
||||||
className={`${classLabel}${attribute.readOnly ? ` ${getClassName("kcInputClassRadioCheckboxLabelDisabled")}` : ""}`}
|
|
||||||
>
|
|
||||||
{advancedMsg(option)}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TextareaTag(props: InputFiledByTypeProps) {
|
|
||||||
const { attribute, formValidationDispatch, getClassName, displayableErrors, valueOrValues } = props;
|
|
||||||
|
|
||||||
assert(typeof valueOrValues === "string");
|
|
||||||
|
|
||||||
const value = valueOrValues;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<textarea
|
|
||||||
id={attribute.name}
|
|
||||||
name={attribute.name}
|
|
||||||
className={getClassName("kcInputClass")}
|
|
||||||
aria-invalid={displayableErrors.length !== 0}
|
|
||||||
disabled={attribute.readOnly}
|
|
||||||
cols={attribute.annotations.inputTypeCols === undefined ? undefined : parseInt(`${attribute.annotations.inputTypeCols}`)}
|
|
||||||
rows={attribute.annotations.inputTypeRows === undefined ? undefined : parseInt(`${attribute.annotations.inputTypeRows}`)}
|
|
||||||
maxLength={attribute.annotations.inputTypeMaxlength === undefined ? undefined : parseInt(`${attribute.annotations.inputTypeMaxlength}`)}
|
|
||||||
value={value}
|
|
||||||
onChange={event =>
|
|
||||||
formValidationDispatch({
|
|
||||||
action: "update",
|
|
||||||
name: attribute.name,
|
|
||||||
valueOrValues: event.target.value
|
|
||||||
})
|
|
||||||
}
|
|
||||||
onBlur={() =>
|
|
||||||
formValidationDispatch({
|
|
||||||
action: "focus lost",
|
|
||||||
name: attribute.name,
|
|
||||||
fieldIndex: undefined
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SelectTag(props: InputFiledByTypeProps) {
|
|
||||||
const { attribute, formValidationDispatch, getClassName, displayableErrors, i18n, valueOrValues } = props;
|
|
||||||
|
|
||||||
const { advancedMsg } = i18n;
|
|
||||||
|
|
||||||
const isMultiple = attribute.annotations.inputType === "multiselect";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<select
|
|
||||||
id={attribute.name}
|
|
||||||
name={attribute.name}
|
|
||||||
className={getClassName("kcInputClass")}
|
|
||||||
aria-invalid={displayableErrors.length !== 0}
|
|
||||||
disabled={attribute.readOnly}
|
|
||||||
multiple={isMultiple}
|
|
||||||
size={attribute.annotations.inputTypeSize === undefined ? undefined : parseInt(`${attribute.annotations.inputTypeSize}`)}
|
|
||||||
value={valueOrValues}
|
|
||||||
onChange={event =>
|
|
||||||
formValidationDispatch({
|
|
||||||
action: "update",
|
|
||||||
name: attribute.name,
|
|
||||||
valueOrValues: (() => {
|
|
||||||
if (isMultiple) {
|
|
||||||
return Array.from(event.target.selectedOptions).map(option => option.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
return event.target.value;
|
|
||||||
})()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
onBlur={() =>
|
|
||||||
formValidationDispatch({
|
|
||||||
action: "focus lost",
|
|
||||||
name: attribute.name,
|
|
||||||
fieldIndex: undefined
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{!isMultiple && <option value=""></option>}
|
|
||||||
{(() => {
|
|
||||||
const options = (() => {
|
|
||||||
walk: {
|
|
||||||
const { inputOptionsFromValidation } = attribute.annotations;
|
|
||||||
|
|
||||||
if (inputOptionsFromValidation === undefined) {
|
|
||||||
break walk;
|
|
||||||
}
|
|
||||||
|
|
||||||
assert(typeof inputOptionsFromValidation === "string");
|
|
||||||
|
|
||||||
const validator = (attribute.validators as Record<string, { options?: string[] }>)[inputOptionsFromValidation];
|
|
||||||
|
|
||||||
if (validator === undefined) {
|
|
||||||
break walk;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (validator.options === undefined) {
|
|
||||||
break walk;
|
|
||||||
}
|
|
||||||
|
|
||||||
return validator.options;
|
|
||||||
}
|
|
||||||
|
|
||||||
return attribute.validators.options?.options ?? [];
|
|
||||||
})();
|
|
||||||
|
|
||||||
return options.map(option => (
|
|
||||||
<option key={option} value={option}>
|
|
||||||
{(() => {
|
|
||||||
if (attribute.annotations.inputOptionLabels !== undefined) {
|
|
||||||
const { inputOptionLabels } = attribute.annotations;
|
|
||||||
|
|
||||||
return advancedMsg(inputOptionLabels[option] ?? option);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (attribute.annotations.inputOptionLabelsI18nPrefix !== undefined) {
|
|
||||||
return advancedMsg(`${attribute.annotations.inputOptionLabelsI18nPrefix}.${option}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return option;
|
|
||||||
})()}
|
|
||||||
</option>
|
|
||||||
));
|
|
||||||
})()}
|
|
||||||
</select>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -1,4 +1,3 @@
|
||||||
|
|
||||||
import type { Meta, StoryObj } from "@storybook/react";
|
import type { Meta, StoryObj } from "@storybook/react";
|
||||||
import { createPageStory } from "../PageStory";
|
import { createPageStory } from "../PageStory";
|
||||||
|
|
||||||
|
|
23
src/main.tsx
23
src/main.tsx
|
@ -1,24 +1,33 @@
|
||||||
/* eslint-disable react-refresh/only-export-components */
|
/* eslint-disable react-refresh/only-export-components */
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { StrictMode, lazy, Suspense } from "react";
|
import { StrictMode, lazy, Suspense } from "react";
|
||||||
//import { getKcContextMock } from "./login/PageStory";
|
|
||||||
//const kcContext = getKcContextMock({ pageId: "register.ftl", overrides: {} });
|
|
||||||
const { kcContext } = window;
|
|
||||||
|
|
||||||
const KcLoginThemeApp = lazy(() => import("./login/KcApp"));
|
const KcLoginThemeApp = lazy(() => import("./login/KcApp"));
|
||||||
const KcAccountThemeApp = lazy(() => import("./account/KcApp"));
|
const KcAccountThemeApp = lazy(() => import("./account/KcApp"));
|
||||||
|
|
||||||
|
let { kcContext } = window;
|
||||||
|
|
||||||
|
// NOTE: This is just to test a specific page when you run `yarn dev`
|
||||||
|
// however the recommended way to develope is to use the Storybook
|
||||||
|
if (kcContext === undefined) {
|
||||||
|
kcContext = (await import("./login/PageStory")).getKcContextMock({
|
||||||
|
pageId: "register.ftl"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
createRoot(document.getElementById("root")!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<Suspense>
|
<Suspense>
|
||||||
{(() => {
|
{(() => {
|
||||||
switch (kcContext?.themeType) {
|
switch (kcContext?.themeType) {
|
||||||
case "login": return <KcLoginThemeApp kcContext={kcContext} />;
|
case "login":
|
||||||
case "account": return <KcAccountThemeApp kcContext={kcContext} />;
|
return <KcLoginThemeApp kcContext={kcContext} />;
|
||||||
case undefined: return <h1>No Keycloak Context</h1>;
|
case "account":
|
||||||
|
return <KcAccountThemeApp kcContext={kcContext} />;
|
||||||
|
case undefined:
|
||||||
|
return <h1>No Keycloak Context</h1>;
|
||||||
}
|
}
|
||||||
})()}
|
})()}
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</StrictMode>
|
</StrictMode>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
7
src/vite-env.d.ts
vendored
7
src/vite-env.d.ts
vendored
|
@ -3,9 +3,8 @@
|
||||||
import type { KcContext as KcContextLogin } from "./login/kcContext";
|
import type { KcContext as KcContextLogin } from "./login/kcContext";
|
||||||
import type { KcContext as KcContextAccount } from "./account/kcContext";
|
import type { KcContext as KcContextAccount } from "./account/kcContext";
|
||||||
|
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
kcContext?: KcContextLogin | KcContextAccount;
|
kcContext?: KcContextLogin | KcContextAccount;
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -1,25 +1,25 @@
|
||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2020",
|
"target": "ES2020",
|
||||||
"useDefineForClassFields": true,
|
"useDefineForClassFields": true,
|
||||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
|
|
||||||
/* Bundler mode */
|
/* Bundler mode */
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"allowImportingTsExtensions": true,
|
"allowImportingTsExtensions": true,
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
/* Linting */
|
/* Linting */
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"noUnusedLocals": true,
|
"noUnusedLocals": true,
|
||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
"noFallthroughCasesInSwitch": true
|
"noFallthroughCasesInSwitch": true
|
||||||
},
|
},
|
||||||
"include": ["src"],
|
"include": ["src"],
|
||||||
"references": [{ "path": "./tsconfig.node.json" }]
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"composite": true,
|
"composite": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"allowSyntheticDefaultImports": true
|
"allowSyntheticDefaultImports": true
|
||||||
},
|
},
|
||||||
"include": ["vite.config.ts"]
|
"include": ["vite.config.ts"]
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,15 +1,11 @@
|
||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from "vite";
|
||||||
import react from '@vitejs/plugin-react'
|
import react from "@vitejs/plugin-react";
|
||||||
import { keycloakify } from "keycloakify/vite-plugin";
|
import { keycloakify } from "keycloakify/vite-plugin";
|
||||||
|
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [
|
plugins: [react(), keycloakify()],
|
||||||
react(),
|
build: {
|
||||||
keycloakify()
|
sourcemap: true
|
||||||
],
|
}
|
||||||
build: {
|
});
|
||||||
sourcemap: true
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
13
yarn.lock
13
yarn.lock
|
@ -5011,10 +5011,10 @@ jsonfile@^6.0.1:
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
graceful-fs "^4.1.6"
|
graceful-fs "^4.1.6"
|
||||||
|
|
||||||
keycloakify@10.0.0-rc.31:
|
keycloakify@10.0.0-rc.33:
|
||||||
version "10.0.0-rc.31"
|
version "10.0.0-rc.33"
|
||||||
resolved "https://registry.yarnpkg.com/keycloakify/-/keycloakify-10.0.0-rc.31.tgz#4ccd4887de0f759ff91f5765a9011c77fbc2230f"
|
resolved "https://registry.yarnpkg.com/keycloakify/-/keycloakify-10.0.0-rc.33.tgz#2a522facaf3138e7c9b699e95ef45cfc73ab0296"
|
||||||
integrity sha512-UMDtVq4jxlihKPnp2OMo2FXTlAEl0PpdN8Bbk0yBxvxgvPuDXazWM2smi4tr48aTLGhx/fWdiyw1mvsOlcFvPA==
|
integrity sha512-rByUFHqsSQ1P9ZsnbCtB02rHfF38J4+dV0gr/oArAviLt6NauO2r3KoRKMtkeT/1OKCvkthvK7cloFWEjBDiBQ==
|
||||||
dependencies:
|
dependencies:
|
||||||
react-markdown "^5.0.3"
|
react-markdown "^5.0.3"
|
||||||
tsafe "^1.6.6"
|
tsafe "^1.6.6"
|
||||||
|
@ -5774,6 +5774,11 @@ prelude-ls@^1.2.1:
|
||||||
resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396"
|
resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396"
|
||||||
integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==
|
integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==
|
||||||
|
|
||||||
|
prettier@3.3.1:
|
||||||
|
version "3.3.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.3.1.tgz#e68935518dd90bb7ec4821ba970e68f8de16e1ac"
|
||||||
|
integrity sha512-7CAwy5dRsxs8PHXT3twixW9/OEll8MLE0VRPCJyl7CkS6VHGPSlsVaWTiASPTyGyYRyApxlaWTzwUxVNrhcwDg==
|
||||||
|
|
||||||
prettier@^3.1.1:
|
prettier@^3.1.1:
|
||||||
version "3.2.5"
|
version "3.2.5"
|
||||||
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.2.5.tgz#e52bc3090586e824964a8813b09aba6233b28368"
|
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.2.5.tgz#e52bc3090586e824964a8813b09aba6233b28368"
|
||||||
|
|
Loading…
Reference in a new issue