diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index 1cf5a65ea9..c6a2e2b0cb 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -27,7 +27,7 @@ jobs: - run: npm install -g yarn - run: yarn install - - run: yarn format:check + - run: yarn format-check - run: yarn check-types - run: yarn build - run: yarn lint diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000000..4723a74f3a --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,75 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as +contributors and maintainers pledge to making participation in our project and +our community a harassment-free experience for everyone, regardless of age, body +size, disability, ethnicity, sex characteristics, gender identity and expression, +level of experience, education, socio-economic status, nationality, personal +appearance, race, religion, or sexual identity and orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment +include: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual attention or + advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic + address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable +behavior and are expected to take appropriate and fair corrective action in +response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or +reject comments, commits, code, wiki edits, issues, and other contributions +that are not aligned to this Code of Conduct, or to ban temporarily or +permanently any contributor for other behaviors that they deem inappropriate, +threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces +when an individual is representing the project or its community. Examples of +representing a project or community include using an official project e-mail +address, posting via an official social media account, or acting as an appointed +representative at an online or offline event. Representation of a project may be +further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior will be reviewed +and investigated and will result in a response that +is deemed necessary and appropriate to the circumstances. The project team is +obligated to maintain confidentiality with regard to the reporter of an incident. +Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good +faith may face temporary or permanent repercussions as determined by other +members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, +available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see +https://www.contributor-covenant.org/faq \ No newline at end of file diff --git a/CODING_GUIDELINES.md b/CODING_GUIDELINES.md new file mode 100644 index 0000000000..274481da83 --- /dev/null +++ b/CODING_GUIDELINES.md @@ -0,0 +1,296 @@ +# Coding Guidelines + +## Typescript + + +The Keycloak UI projects uses best practices based off the official [React TypeScript Cheat sheet](https://react-typescript-cheatsheet.netlify.app/), with modifications for this project. The React TypeScript Cheat sheet is maintained and used by developers through out the world, and is a place where developers can bring together lessons learned using TypeScript and React. + +### Imports + +Since we are using TypeScript 4.x + for this project, default imports should conform to the new standard set forth in TypeScript 2.7: + +```javascript +import React from "react"; +import ReactDOM from "react-dom"; +``` + +For imports that are not the default import use the following syntax: + +```javascript +import { X1, X2, ... Xn } from "package-x"; +``` + +### Props + +For props we are using **type** instead of **interfaces**. The reason to use types instead of interfaces is for consistency between the views and because it"s more constrained (See [Types or Interfaces](https://react-typescript-cheatsheet.netlify.app/docs/basic/getting-started/types_or_interfaces) for more clarification). By using types we are ensuring that we will not deviate from the agreed upon [contract](https://dev.to/reyronald/typescript-types-or-interfaces-for-react-component-props-1408). + +The following is an example of using a type for props: + +```javascript +import React, { ReactNode } from "react" + +... + +export type ExampleComponentProps = { + message: string; + children: ReactNode; +} +``` + +### State objects should be types + +When maintaining state for a component that requires it's state to be defined by an object, it is recommended that you use a [type instead of an interface](https://react-typescript-cheatsheet.netlify.app/docs/basic/getting-started/types_or_interfaces). For example if you need to maintain the currentApiId and isExpanded in a single object you can do the following: + +```javascript +type ApiDrawerState = { + currentApiId: string, + isExpanded: boolean, +}; +``` + +### State management + +We have made a conscious decision to stay away from state management technologies such as Redux. These overarching state management schemes tend to be overly complex and encourage dumping everything into the global state. + +Instead, we are following a simple philosophy that state should remain close to where it is used and moved to a wider scope only as truly needed. This encourages encapsulation and makes management of the state much simpler. + +The way this plays out in our application is that we first prefer state to remain in the scope of the component that uses it. If the state is required by more than one component, we move to a more complex strategy for management of that state. In other words, in order of preference, state should be managed by: +1. Storing in the component that uses it. +2. If #1 is not sufficient, [lift state up](https://reactjs.org/docs/lifting-state-up.html). +3. If #2 is not sufficient, try [component composition](https://reactjs.org/docs/context.html#before-you-use-context). +4. If #3, is not sufficient, use a [global context](https://reactjs.org/docs/context.html). + +A good tutorial on this approach is found in [Kent Dodds’ blog](https://kentcdodds.com/blog/application-state-management-with-react). + +### Interfaces + +Interfaces should be used for all public facing API definitions. A table describing when to use interfaces vs. types can be found [here](https://react-typescript-cheatsheet.netlify.app/docs/basic/getting-started/types_or_interfaces). + +### Function Components + +This project uses function components and hooks over class components. When coding function components in typescript, a developer should include any specific props that they need. + +```javascript +import React, { FunctionComponent } from "react"; + +... + +export const ExampleComponent: FunctionComponent = ({ message, children }: ExampleComponentProps) => ( + +
{message}
+
{children}
+ > +); +``` + +For components that do not have any additional props an empty object should be used instead: + +```javascript +import React, { FunctionComponent } from "react"; + +... + +export const ExampleNoPropsComponent: FunctionComponent<{}> = () => ( +
Example Component with no props
+); +``` + +Additional details around function components can be found [here](https://react-typescript-cheatsheet.netlify.app/docs/basic/getting-started/function_components). + +### Hooks + +When using hooks with Typescript there are few recommendations that we follow below. Additional recommendations besides the ones mentioned in this document can be found [here](https://react-typescript-cheatsheet.netlify.app/docs/basic/getting-started/hooks). + +### Inference vs Types for useState + +Currently we recommend using inference for the primitive types booleans, numbers, and strings when using useState. Anything other then these 3 types should use a declarative syntax to specify what is expected. For example the following is an example of how to use inference: + +```javascript +const [isEnabled, setIsEnabled] = useState(false); +``` + +Here is an example how to use a declarative syntax. When using a declarative syntax, if the value can be null, that will also need to be specified: + +```javascript +const [user, setUser] = useState(null); + +... + +setUser(newUser); + + +``` + +#### useReducers + +When using reducers make sure you specify the [return type and not use inference](https://react-typescript-cheatsheet.netlify.app/docs/basic/getting-started/hooks#usereducer). + +#### useEffect + +For useEffect only [return the function or undefined](https://react-typescript-cheatsheet.netlify.app/docs/basic/getting-started/hooks#useeffect). + +#### useRef + +When using useRef there are two options with Typescript. The first one is when creating a [read-only ref](https://react-typescript-cheatsheet.netlify.app/docs/basic/getting-started/hooks#useref). + +```javascript +const refExample = useRef(null!); +``` + +By passing in null! it will prevent Typescript from returning an error saying refExample maybe null. + +The second option is for creating [mutable refs](https://react-typescript-cheatsheet.netlify.app/docs/basic/getting-started/hooks#useref) that you will manage. + +```javascript +const refExampleMutable = (useRef < HTMLElement) | (null > null); +``` + +### Additional Typescript Pointers + +Besides the details outlined above a list of recommendations for Typescript is maintained by several Typescript React developers [here](https://react-typescript-cheatsheet.netlify.app/). This is a great reference to use for any additional questions that are not outlined within the coding standards. + +## CSS + +We use custom CSS in rare cases where PatternFly styling does not meet our design guidelines. If styling needs to be added, we should first check that the PatternFly component is being properly built and whether a variant is already provided to meet the use case. Next, PatternFly layouts should be used for most positioning of components. For one-off tweaks (e.g. spacing an icon slightly away from the text next to it), a PatternFly utility class should be used. In all cases, PatternFly variables should be used for colors, spacing, etc. rather than hard coding color or pixel values. + +We will use one global CSS file to surface customization variables. Styles particular to a component should be located in a .CSS file within the component’s folder. A modified BEM naming convention should be used as detailed below. + +### Location of files, location of classes + +* Global styling should be located…? *./public/index.css*. + +* The CSS relating to a single component should be located in a file within each component’s folder. + +### Naming CSS classes + +PatternFly reference https://pf4.patternfly.org/guidelines#variables + +For the Keycloak admin console, we modify the PatternFly convention to namespace the classes and variables to the Keycloak packages. + +**Class name** +```css +.keycloak-admin--block[__element][--modifier][--state][--breakpoint][--pseudo-element] +``` + +**Examples of custom CSS classes** +```css +// Modification to all data tables throughout Keycloak admin +.keycloak-admin--data-table { +... +} + +// Data tables throughout keycloak that are marked as compact +.keycloak-admin--data-table--compact { +... +} + +// Child elements of a compact data-table +// Don’t increase specificity with a selector like this: +// .keycloak-admin--data-table--compact .data-table-item +// Instead, follow the pattern for a single class on the child +.keycloak-admin--data-table__data-table-item--compact { +... +} + +// Compact data table just in the management console at the lg or higher breakpoint +.keycloak-admin--data-table--compact--lg { +... +} +``` +### Naming CSS custom properties and using PatternFly’s custom properties + +Usually, PatternFly components will properly style components. Sometimes problems with the spacing or other styling indicate that a wrapper component is missing or that components haven’t been put together quite as intended. Often there is a variant of the component available that will accomplish the design. + +However, there are other times when modifications must be made to the styling provided by PatternFly, or when styling a custom component. In these cases, PatternFly custom properties (CSS variables) should be used as attribute values. PatternFly defines custom properties for colors, spacing, border width, box shadow, and more. Besides a full color palette, colors are defined specifically for borders, statuses (success, warning, danger, info), backgrounds, etc. + +These values can be seen in the [PatternFly design guidelines](https://www.patternfly.org/v4/design-guidelines/styles/colors) and a [full listing of variables](https://www.patternfly.org/v4/documentation/overview/global-css-variables) can be found in the documentation section. + +For the Keycloak admin console, we modify the PatternFly convention to namespace the classes and variables to the Keycloak packages. + +**Custom property** +```css +--keycloak-admin--block[__element][--modifier][--state][--breakpoint][--pseudo-element]--PropertyCamelCase +``` + +**Example of a CSS custom property** +```css +// Modify the height of the brand image +--keycloak-admin--brand--Height: var(--pf-global--spacer--xl); +``` + +**Example** +```css +// Don’t increase specificity +// Don’t use pixel values +.keycloak-admin--manage-columns__modal .pf-c-dropdown { + margin-bottom: 24px +} + +// Do use a new class +// Do use a PatternFly global spacer variable +.keycloak-admin--manage-columns__dropdown { + margin-bottom: var(--pf-global--spacer--xl); +} +``` +### Using utility classes + +Utility classes can be used to add specific styling to a component, such as margin-bottom or padding. However, their use should be limited to one-off styling needs. + +For example, instead of using the utility class for margin-right multiple times, we should define a new Keycloak admin console class that adds this *margin-right: var(--pf-global--spacer--sm);* and in this example, the new class can set the color appropriately as well. + +**Using a utility class ** +```css +switch (titleStatus) { + case "success": + return ( + <> + {" "} + {titleText}{" "} + + ); + case "failure": + return ( + <> + {" "} + {titleText}{" "} + + ); + } + ``` +**Better way with a custom class** +```css +switch (titleStatus) { + case "success": + return ( + <> + {" "} + {titleText}{" "} + + ); + case "failure": + return ( + <> + {" "} + {titleText}{" "} + + ); + } +``` + +## Resources + +* [PatternFly Docs](https://www.patternfly.org/v4/) +* [Katacoda PatternFly tutorials](https://www.patternfly.org/v4/documentation/react/overview/react-training) +* [PatternFly global CSS variables](https://www.patternfly.org/v4/documentation/overview/global-css-variables) +* [PatternFly CSS utility classes](https://www.patternfly.org/v4/documentation/core/utilities/accessibility) +* [React Typescript Cheat sheet](https://react-typescript-cheatsheet.netlify.app/) \ No newline at end of file diff --git a/README.md b/README.md index 11de418ed5..b1b4765109 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,26 @@ # Keycloak Admin Console V2 -This project is the next generation of the Keycloak Administration Console. It is written with React and PatternFly 4. +This project is the next generation of the Keycloak Administration Console. It is written with React and [PatternFly 4][1]. ### Development Instructions -For development on this project you will need a running Keycloak server listening on port 8180. You will also need [yarn installed on your local machine.](https://classic.yarnpkg.com) +For development on this project you will need a running Keycloak server listening on port 8180. You will also need [yarn installed on your local machine.][2] 1. Start keycloak - * Download Keycloak server from [https://www.keycloak.org/downloads](https://www.keycloak.org/downloads) - * Start Keycloak server like this from the bin directory: - ```bash - $> standalone -Djboss.socket.binding.port-offset=100 - ``` - * or download and run with one command + * Download and run with one command ```bash $> ./start.js ``` -1. Go to the clients section of the exising Keycloak Admin Console and add the client + * or download Keycloak server from [keycloak downloads page][3] unpack and run it like: + ```bash + $> cd /bin + $> standalone -Djboss.socket.binding.port-offset=100 + ``` +1. Go to the clients section of the existing Keycloak Admin Console and add the client * like this: ![realm settings](./realm-settings.png "Realm Settings") * or click on the "Select file" button and import `security-admin-console-v2.json` -1. Install dependecies and run: +1. Install dependencies and run: ```bash $> yarn $> yarn start @@ -30,6 +30,7 @@ For development on this project you will need a running Keycloak server listenin ```bash $> yarn format +$> yarn check-types $> yarn lint ``` @@ -40,3 +41,7 @@ $> npx grunt switch-rh-sso ``` To switch back just do a `git checkout public` + +[1]: https://www.patternfly.org/v4/ +[2]: (https://classic.yarnpkg.com) +[3]: https://www.keycloak.org/downloads \ No newline at end of file diff --git a/package.json b/package.json index 3ac47420fd..1bf07e565b 100644 --- a/package.json +++ b/package.json @@ -4,16 +4,16 @@ "main": "index.js", "license": "MIT", "scripts": { - "postinstall": "grunt", - "start": "snowpack dev", "build": "snowpack build", - "test": "jest", + "build-storybook": "build-storybook -s public", "check-types": "tsc -p ./", "format": "prettier --write \"src/**/*.{js,jsx,ts,tsx}\"", - "format:check": "prettier --check \"src/**/*.{js,jsx,ts,tsx}\"", + "format-check": "prettier --check \"src/**/*.{js,jsx,ts,tsx}\"", "lint": "eslint ./src/**/*.ts*", + "postinstall": "grunt", + "start": "snowpack dev", "storybook": "start-storybook -p 6006 -s public", - "build-storybook": "build-storybook -s public" + "test": "jest" }, "dependencies": { "@patternfly/patternfly": "^4.42.2", diff --git a/src/App.tsx b/src/App.tsx index fdc2c55f22..d747b2e36a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { ReactNode } from "react"; import { Page } from "@patternfly/react-core"; import { BrowserRouter as Router, Route, Switch } from "react-router-dom"; @@ -8,31 +8,40 @@ import { Help } from "./components/help-enabler/HelpHeader"; import { RealmContextProvider } from "./context/realm-context/RealmContext"; import { WhoAmIContextProvider } from "./context/whoami/WhoAmI"; +import { ServerInfoProvider } from "./context/server-info/ServerInfoProvider"; +import { AlertProvider } from "./components/alert/Alerts"; import { routes } from "./route-config"; import { PageBreadCrumbs } from "./components/bread-crumb/PageBreadCrumbs"; +const AppContexts = ({ children }: { children: ReactNode }) => ( + + + + + {children} + + + + +); export const App = () => { return ( - - - - - } - isManagedSidebar - sidebar={} - breadcrumb={} - > - - {routes(() => {}).map((route, i) => ( - - ))} - - - - - - + + + } + isManagedSidebar + sidebar={} + breadcrumb={} + > + + {routes(() => {}).map((route, i) => ( + + ))} + + + + ); }; diff --git a/src/client-scopes/ClientScopesSection.tsx b/src/client-scopes/ClientScopesSection.tsx index a0c9dbd4af..a72509f1c7 100644 --- a/src/client-scopes/ClientScopesSection.tsx +++ b/src/client-scopes/ClientScopesSection.tsx @@ -1,12 +1,11 @@ -import React, { useContext, useState } from "react"; -import { Button, PageSection } from "@patternfly/react-core"; +import React, { useContext, useEffect, useState } from "react"; +import { Button, PageSection, Spinner } from "@patternfly/react-core"; import { useTranslation } from "react-i18next"; import { useHistory } from "react-router-dom"; import { RealmContext } from "../context/realm-context/RealmContext"; import { HttpClientContext } from "../context/http-service/HttpClientContext"; import { ClientRepresentation } from "../realm/models/Realm"; -import { DataLoader } from "../components/data-loader/DataLoader"; import { TableToolbar } from "../components/table-toolbar/TableToolbar"; import { ClientScopeList } from "./ClientScopesList"; import { ViewHeader } from "../components/view-header/ViewHeader"; @@ -14,16 +13,30 @@ import { ViewHeader } from "../components/view-header/ViewHeader"; export const ClientScopesSection = () => { const { t } = useTranslation("client-scopes"); const history = useHistory(); + const [rawData, setRawData] = useState(); + const [filteredData, setFilteredData] = useState(); - const [max, setMax] = useState(10); - const [first, setFirst] = useState(0); const httpClient = useContext(HttpClientContext)!; const { realm } = useContext(RealmContext); - const loader = async () => { - return await httpClient - .doGet(`/admin/realms/${realm}/client-scopes`, { params: { first, max } }) - .then((r) => r.data as ClientRepresentation[]); + useEffect(() => { + (async () => { + if (filteredData) { + return filteredData; + } + const result = await httpClient.doGet( + `/admin/realms/${realm}/client-scopes` + ); + setRawData(result.data!); + })(); + }, []); + + const filterData = (search: string) => { + setFilteredData( + rawData!.filter((group) => + group.name.toLowerCase().includes(search.toLowerCase()) + ) + ); }; return ( <> @@ -32,28 +45,29 @@ export const ClientScopesSection = () => { subKey="client-scopes:clientScopeExplain" /> - - {(scopes) => ( - { - setFirst(first); - setMax(max); - }} - toolbarItem={ - - } - > - - - )} - + {!rawData && ( +
+ +
+ )} + {rawData && ( + + history.push("/client-scopes/add-client-scopes/") + } + > + {t("createClientScope")} + + } + > + + + )}
); diff --git a/src/client-scopes/__tests__/mock-client-scope.json b/src/client-scopes/__tests__/mock-client-scope.json new file mode 100644 index 0000000000..b76f38de5b --- /dev/null +++ b/src/client-scopes/__tests__/mock-client-scope.json @@ -0,0 +1,58 @@ +{ + "id": "d695a07e-f0e0-4248-b606-21aaf6b54055", + "name": "dog", + "description": "Great for Juice", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "gui.order": "0", + "consent.screen.text": "So cool!" + }, + "protocolMappers": [ + { + "id": "f8673b15-97d8-4b7c-b3a2-23394041fb40", + "name": "upn", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "username", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "upn", + "jsonType.label": "String" + } + }, + { + "id": "bd2e4c38-2e00-4674-9a3e-d15a88d2565a", + "name": "realm roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "consentRequired": false, + "config": { + "user.attribute": "foo", + "access.token.claim": "true", + "claim.name": "realm_access.roles", + "jsonType.label": "String", + "multivalued": "true" + } + }, + { + "id": "fed9caae-e3e4-4711-9035-087193dd25c4", + "name": "username", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "username", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "preferred_username", + "jsonType.label": "String" + } + } + ] +} diff --git a/src/client-scopes/add/NewClientScopeForm.tsx b/src/client-scopes/add/NewClientScopeForm.tsx index 54e1545f52..802cc450ea 100644 --- a/src/client-scopes/add/NewClientScopeForm.tsx +++ b/src/client-scopes/add/NewClientScopeForm.tsx @@ -1,4 +1,5 @@ import React, { useContext, useState } from "react"; +import { useHistory } from "react-router-dom"; import { ActionGroup, AlertVariant, @@ -7,6 +8,7 @@ import { FormGroup, PageSection, Select, + SelectOption, SelectVariant, Switch, TextInput, @@ -19,18 +21,23 @@ import { HelpItem } from "../../components/help-enabler/HelpItem"; import { HttpClientContext } from "../../context/http-service/HttpClientContext"; import { RealmContext } from "../../context/realm-context/RealmContext"; import { useAlerts } from "../../components/alert/Alerts"; +import { useLoginProviders } from "../../context/server-info/ServerInfoProvider"; +import { ViewHeader } from "../../components/view-header/ViewHeader"; export const NewClientScopeForm = () => { const { t } = useTranslation("client-scopes"); - const { register, control, handleSubmit } = useForm< + const helpText = useTranslation("client-scopes-help").t; + const { register, control, handleSubmit, errors } = useForm< ClientScopeRepresentation >(); + const history = useHistory(); const httpClient = useContext(HttpClientContext)!; const { realm } = useContext(RealmContext); + const providers = useLoginProviders(); const [open, isOpen] = useState(false); - const [add, Alerts] = useAlerts(); + const { addAlert } = useAlerts(); const save = async (clientScopes: ClientScopeRepresentation) => { try { @@ -44,139 +51,177 @@ export const NewClientScopeForm = () => { `/admin/realms/${realm}/client-scopes`, clientScopes ); - add(t("createClientScopeSuccess"), AlertVariant.success); + addAlert(t("createClientScopeSuccess"), AlertVariant.success); } catch (error) { - add(`${t("createClientScopeError")} '${error}'`, AlertVariant.danger); + addAlert( + `${t("createClientScopeError")} '${error}'`, + AlertVariant.danger + ); } }; return ( - - -
- - {t("name")} - - } - fieldId="kc-name" - isRequired - > - - - - {t("description")} - - } - fieldId="kc-description" - > - - - - {t("protocol")} - - } - fieldId="kc-protocol" - > - ( - - )} - /> - - - {t("displayOnConsentScreen")}{" "} - - - } - fieldId="kc-display.on.consent.screen" - > - ( - + + + + + - )} - /> - - - {t("consentScreenText")}{" "} - - - } - fieldId="kc-consent-screen-text" - > - - - - {t("guiOrder")} - - } - fieldId="kc-gui-order" - > - - - - - - - - + } + fieldId="kc-name" + isRequired + validated={errors.name ? "error" : "default"} + helperTextInvalid={t("common:required")} + > + +
+ + } + fieldId="kc-description" + > + + + + } + fieldId="kc-protocol" + > + ( + + )} + /> + + + } + fieldId="kc-display.on.consent.screen" + > + ( + + )} + /> + + + } + fieldId="kc-consent-screen-text" + > + + + + } + fieldId="kc-gui-order" + > + + + + + + + +
+ ); }; diff --git a/src/client-scopes/details/MapperList.tsx b/src/client-scopes/details/MapperList.tsx new file mode 100644 index 0000000000..0d5616dc0a --- /dev/null +++ b/src/client-scopes/details/MapperList.tsx @@ -0,0 +1,151 @@ +import React, { useContext, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + AlertVariant, + Dropdown, + DropdownItem, + DropdownToggle, +} from "@patternfly/react-core"; +import { + Table, + TableBody, + TableHeader, + TableVariant, +} from "@patternfly/react-table"; +import { CaretDownIcon } from "@patternfly/react-icons"; + +import { useServerInfo } from "../../context/server-info/ServerInfoProvider"; +import { + ClientScopeRepresentation, + ProtocolMapperRepresentation, +} from "../models/client-scope"; +import { TableToolbar } from "../../components/table-toolbar/TableToolbar"; +import { ListEmptyState } from "../../components/list-empty-state/ListEmptyState"; +import { HttpClientContext } from "../../context/http-service/HttpClientContext"; +import { RealmContext } from "../../context/realm-context/RealmContext"; +import { useAlerts } from "../../components/alert/Alerts"; + +type MapperListProps = { + clientScope: ClientScopeRepresentation; +}; + +type Row = { + name: string; + category: string; + type: string; + priority: number; +}; + +export const MapperList = ({ clientScope }: MapperListProps) => { + const { t } = useTranslation("client-scopes"); + const httpClient = useContext(HttpClientContext)!; + const { realm } = useContext(RealmContext); + const { addAlert } = useAlerts(); + + const [filteredData, setFilteredData] = useState< + { mapper: ProtocolMapperRepresentation; cells: Row }[] + >(); + const [mapperAction, setMapperAction] = useState(false); + const mapperList = clientScope.protocolMappers!; + const mapperTypes = useServerInfo().protocolMapperTypes[ + clientScope.protocol! + ]; + + if (!mapperList) { + return ( + {}} + /> + ); + } + + const data = mapperList + .map((mapper) => { + const mapperType = mapperTypes.filter( + (type) => type.id === mapper.protocolMapper + )[0]; + return { + mapper, + cells: { + name: mapper.name, + category: mapperType.category, + type: mapperType.name, + priority: mapperType.priority, + } as Row, + }; + }) + .sort((a, b) => a.cells.priority - b.cells.priority); + + const filterData = (search: string) => { + setFilteredData( + data.filter((column) => + column.cells.name.toLowerCase().includes(search.toLowerCase()) + ) + ); + }; + + return ( + setMapperAction(false)} + toggle={ + setMapperAction(!mapperAction)} + toggleIndicator={CaretDownIcon} + > + {t("addMapper")} + + } + isOpen={mapperAction} + dropdownItems={[ + + {t("fromPredefinedMapper")} + , + + {t("byConfiguration")} + , + ]} + /> + } + > + { + return { cells: Object.values(cell.cells), mapper: cell.mapper }; + })} + aria-label={t("clientScopeList")} + actions={[ + { + title: t("common:delete"), + onClick: async (_, rowId) => { + try { + await httpClient.doDelete( + `/admin/realms/${realm}/client-scopes/${clientScope.id}/protocol-mappers/models/${data[rowId].mapper.id}` + ); + addAlert(t("mappingDeletedSuccess"), AlertVariant.success); + } catch (error) { + addAlert( + t("mappingDeletedError", { error }), + AlertVariant.danger + ); + } + }, + }, + ]} + > + + +
+
+ ); +}; diff --git a/src/client-scopes/help.json b/src/client-scopes/help.json new file mode 100644 index 0000000000..0d558b365d --- /dev/null +++ b/src/client-scopes/help.json @@ -0,0 +1,10 @@ +{ + "client-scopes-help": { + "name": "Name of the client scope. Must be unique in the realm. Name should not contain space characters as it is used as value of scope parameter", + "description": "Description of the client scope", + "protocol": "Which SSO protocol configuration is being supplied by this client scope", + "displayOnConsentScreen": "If on, and this client scope is added to some client with consent required, the text specified by 'Consent Screen Text' will be displayed on consent screen. If off, this client scope will not be displayed on the consent screen", + "consentScreenText": "Text that will be shown on the consent screen when this client scope is added to some client with consent required. Defaults to name of client scope if it is not filled", + "guiOrder": "Specify order of the provider in GUI (such as in Consent page) as integer" + } +} diff --git a/src/client-scopes/messages.json b/src/client-scopes/messages.json index ffedf6283e..b5a154dccb 100644 --- a/src/client-scopes/messages.json +++ b/src/client-scopes/messages.json @@ -3,11 +3,27 @@ "createClientScope": "Create client scope", "clientScopeList": "List of client scopes", "clientScopeExplain": "Client scopes allow you to define a common set of protocol mappers and roles, which are shared between multiple clients", + "searchFor": "Search for client scope", "name": "Name", "description": "Description", + "category": "Category", + "type": "Type", + "priority": "Priority", "protocol": "Protocol", "createClientScopeSuccess": "Client scope created", "createClientScopeError": "Could not create client scope:", + "settings": "Settings", + "mappers": "Mappers", + "mappersSearchFor": "Search for mapper", + "addMapper": "Add mapper", + "fromPredefinedMapper": "From predefined mappers", + "byConfiguration": "By configuration", + "emptyMappers": "No mappers", + "emptyMappersInstructions": "If you want to add mappers, please click the button below to add some predefined mappers or to configure a new mapper.", + "emptyPrimaryAction": "Add predefined mapper", + "emptySecondaryAction": "Configure a new mapper", + "mappingDeletedSuccess": "Mapping successfully deleted", + "mappingDeletedError": "Could not delete mapping: '{{error}}'", "displayOnConsentScreen": "Display on consent screen", "consentScreenText": "Consent screen text", "guiOrder": "GUI Order" diff --git a/src/clients/ClientList.tsx b/src/clients/ClientList.tsx index 4756402057..f140c4a862 100644 --- a/src/clients/ClientList.tsx +++ b/src/clients/ClientList.tsx @@ -35,7 +35,7 @@ export const ClientList = ({ baseUrl, clients, refresh }: ClientListProps) => { const { t } = useTranslation("clients"); const httpClient = useContext(HttpClientContext)!; const { realm } = useContext(RealmContext); - const [add, Alerts] = useAlerts(); + const { addAlert } = useAlerts(); const emptyFormatter = (): IFormatter => (data?: IFormatterValueType) => { return data ? data : "—"; @@ -85,7 +85,6 @@ export const ClientList = ({ baseUrl, clients, refresh }: ClientListProps) => { }); return ( <> - { `/admin/realms/${realm}/clients/${data[rowId].client.id}` ); refresh(); - add(t("clientDeletedSuccess"), AlertVariant.success); + addAlert(t("clientDeletedSuccess"), AlertVariant.success); } catch (error) { - add(`${t("clientDeleteError")} ${error}`, AlertVariant.danger); + addAlert( + `${t("clientDeleteError")} ${error}`, + AlertVariant.danger + ); } }, }, diff --git a/src/clients/ClientSettings.tsx b/src/clients/ClientSettings.tsx index 0bfb2fd15d..462916333e 100644 --- a/src/clients/ClientSettings.tsx +++ b/src/clients/ClientSettings.tsx @@ -36,7 +36,7 @@ export const ClientSettings = () => { const { t } = useTranslation("clients"); const httpClient = useContext(HttpClientContext)!; const { realm } = useContext(RealmContext); - const [addAlert, Alerts] = useAlerts(); + const { addAlert } = useAlerts(); const { id } = useParams<{ id: string }>(); const [name, setName] = useState(""); @@ -139,7 +139,6 @@ export const ClientSettings = () => { }} /> - { const { t } = useTranslation("clients"); @@ -38,7 +38,7 @@ export const ClientsSection = () => { useEffect(() => { loader(); - }, []); + }, [first, max]); return ( <> @@ -48,12 +48,12 @@ export const ClientsSection = () => { /> {!clients && ( -
+
)} {clients && ( - { refresh={loader} baseUrl={keycloak!.authServerUrl()!} /> - + )} diff --git a/src/clients/__tests__/__snapshots__/ClientList.test.tsx.snap b/src/clients/__tests__/__snapshots__/ClientList.test.tsx.snap index 131a750aad..77316a8552 100644 --- a/src/clients/__tests__/__snapshots__/ClientList.test.tsx.snap +++ b/src/clients/__tests__/__snapshots__/ClientList.test.tsx.snap @@ -746,11 +746,6 @@ Object {
-
-
    -
, "container":
{ - const httpClient = useContext(HttpClientContext)!; const { t } = useTranslation(); - const { errors, control, register } = form; + const { errors, control } = form; - const [providers, setProviders] = useState([]); + const providers = useLoginProviders(); const [open, isOpen] = useState(false); - useEffect(() => { - (async () => { - const response = await httpClient.doGet( - "/admin/serverinfo" - ); - const providers = Object.entries( - response.data!.providers["login-protocol"].providers - ); - setProviders(["", ...new Map(providers.sort(sortProvider)).keys()]); - })(); - }, []); - return (
{ }} selections={value} variant={SelectVariant.single} - aria-label="Select Encryption type" + aria-label={t("selectEncryptionType")} + placeholderText={t("common:selectOne")} isOpen={open} > {providers.map((option) => ( ))} diff --git a/src/clients/add/NewClientForm.tsx b/src/clients/add/NewClientForm.tsx index f29a94d771..ac3c46f8d8 100644 --- a/src/clients/add/NewClientForm.tsx +++ b/src/clients/add/NewClientForm.tsx @@ -37,15 +37,15 @@ export const NewClientForm = () => { directAccessGrantsEnabled: false, standardFlowEnabled: false, }); - const [add, Alerts] = useAlerts(); + const { addAlert } = useAlerts(); const methods = useForm({ defaultValues: client }); const save = async () => { try { await httpClient.doPost(`/admin/realms/${realm}/clients`, client); - add("Client created", AlertVariant.success); + addAlert(t("createSuccess"), AlertVariant.success); } catch (error) { - add(`Could not create client: '${error}'`, AlertVariant.danger); + addAlert(t("createError", { error }), AlertVariant.danger); } }; @@ -93,7 +93,6 @@ export const NewClientForm = () => { const title = t("createClient"); return ( <> - { const form = useForm(); const { register, handleSubmit, setValue } = form; - const [add, Alerts] = useAlerts(); + const { addAlert } = useAlerts(); const handleFileChange = (value: string | File) => { const defaultClient = { @@ -45,14 +45,13 @@ export const ImportForm = () => { const save = async (client: ClientRepresentation) => { try { await httpClient.doPost(`/admin/realms/${realm}/clients`, client); - add(t("clientImportSuccess"), AlertVariant.success); + addAlert(t("clientImportSuccess"), AlertVariant.success); } catch (error) { - add(`${t("clientImportError")} '${error}'`, AlertVariant.danger); + addAlert(`${t("clientImportError")} '${error}'`, AlertVariant.danger); } }; return ( <> - void, - () => ReactElement, - (key: number) => void, - AlertType[] -] { +type AlertProps = { + addAlert: (message: string, variant?: AlertVariant) => void; +}; + +export const AlertContext = createContext({ + addAlert: () => {}, +}); + +export const useAlerts = () => useContext(AlertContext); + +export const AlertProvider = ({ children }: { children: ReactNode }) => { const [alerts, setAlerts] = useState([]); const createId = () => new Date().getTime(); @@ -15,7 +20,7 @@ export function useAlerts(): [ setAlerts((alerts) => [...alerts.filter((el) => el.key !== key)]); }; - const add = ( + const addAlert = ( message: string, variant: AlertVariant = AlertVariant.default ) => { @@ -24,7 +29,10 @@ export function useAlerts(): [ setTimeout(() => hideAlert(key), 8000); }; - const Panel = () => ; - - return [add, Panel, hideAlert, alerts]; -} + return ( + + + {children} + + ); +}; diff --git a/src/components/alert/__tests__/Alerts.test.tsx b/src/components/alert/__tests__/Alerts.test.tsx index 70e716f494..5a66b11296 100644 --- a/src/components/alert/__tests__/Alerts.test.tsx +++ b/src/components/alert/__tests__/Alerts.test.tsx @@ -4,17 +4,16 @@ import { mount } from "enzyme"; import { act } from "react-dom/test-utils"; import { AlertPanel } from "../AlertPanel"; -import { useAlerts } from "../Alerts"; +import { AlertProvider, useAlerts } from "../Alerts"; jest.useFakeTimers(); const WithButton = () => { - const [add, _, hide, alerts] = useAlerts(); + const { addAlert } = useAlerts(); return ( - <> - - - + + + ); }; diff --git a/src/components/alert/__tests__/__snapshots__/Alerts.test.tsx.snap b/src/components/alert/__tests__/__snapshots__/Alerts.test.tsx.snap index ef28412b6a..5f806bae2b 100644 --- a/src/components/alert/__tests__/__snapshots__/Alerts.test.tsx.snap +++ b/src/components/alert/__tests__/__snapshots__/Alerts.test.tsx.snap @@ -2,164 +2,101 @@ exports[`remove alert after timeout: cleared alert 1`] = ` - - + - -
    - - } + - +
      + + } > -
        - - - - - - + + + `; exports[`remove alert after timeout: with alert 1`] = ` - - + - -
          -
        • -
          -
          - -
          -

          - - Hello -

          -
          - -
          - -
          -
        • -
        - - } + - +
          + + } > -
            - - - - - - + + + `; diff --git a/src/components/data-loader/DataLoader.tsx b/src/components/data-loader/DataLoader.tsx index 5912be950b..8f4dcad7d3 100644 --- a/src/components/data-loader/DataLoader.tsx +++ b/src/components/data-loader/DataLoader.tsx @@ -33,7 +33,7 @@ export function DataLoader(props: DataLoaderProps) { return props.children; } return ( -
            +
            ); diff --git a/src/components/download-dialog/DownloadDialog.tsx b/src/components/download-dialog/DownloadDialog.tsx new file mode 100644 index 0000000000..0bce56529a --- /dev/null +++ b/src/components/download-dialog/DownloadDialog.tsx @@ -0,0 +1,192 @@ +import React, { useContext, useState, useEffect, ReactElement } from "react"; +import { + Alert, + AlertVariant, + Form, + FormGroup, + Select, + SelectOption, + SelectVariant, + Stack, + StackItem, + TextArea, +} from "@patternfly/react-core"; +import { ConfirmDialogModal } from "../confirm-dialog/ConfirmDialog"; +import { HttpClientContext } from "../../context/http-service/HttpClientContext"; +import { RealmContext } from "../../context/realm-context/RealmContext"; +import { HelpItem } from "../help-enabler/HelpItem"; +import { useTranslation } from "react-i18next"; + +export type DownloadDialogProps = { + id: string; + protocol?: string; +}; + +type DownloadDialogModalProps = DownloadDialogProps & { + open: boolean; + toggleDialog: () => void; +}; + +const serverInfo = [ + { + id: "keycloak-oidc-jboss-subsystem-cli", + protocol: "openid-connect", + downloadOnly: false, + displayType: "Keycloak OIDC JBoss Subsystem CLI", + helpText: + "CLI script you must edit and apply to your client app server. This type of configuration is useful when you can't or don't want to crack open your WAR file.", + filename: "keycloak-oidc-subsystem.cli", + mediaType: "text/plain", + }, + { + id: "keycloak-oidc-jboss-subsystem", + protocol: "openid-connect", + downloadOnly: false, + displayType: "Keycloak OIDC JBoss Subsystem XML", + helpText: + "XML snippet you must edit and add to the Keycloak OIDC subsystem on your client app server. This type of configuration is useful when you can't or don't want to crack open your WAR file.", + filename: "keycloak-oidc-subsystem.xml", + mediaType: "application/xml", + }, + { + id: "keycloak-oidc-keycloak-json", + protocol: "openid-connect", + downloadOnly: false, + displayType: "Keycloak OIDC JSON", + helpText: + "keycloak.json file used by the Keycloak OIDC client adapter to configure clients. This must be saved to a keycloak.json file and put in your WEB-INF directory of your WAR file. You may also want to tweak this file after you download it.", + filename: "keycloak.json", + mediaType: "application/json", + }, +]; + +export const useDownloadDialog = ( + props: DownloadDialogProps +): [() => void, () => ReactElement] => { + const [show, setShow] = useState(false); + + function toggleDialog() { + setShow((show) => !show); + } + + const Dialog = () => ( + + ); + return [toggleDialog, Dialog]; +}; + +export const DownloadDialog = ({ + id, + open, + toggleDialog, + protocol = "openid-connect", +}: DownloadDialogModalProps) => { + const httpClient = useContext(HttpClientContext)!; + const { realm } = useContext(RealmContext); + const { t } = useTranslation("common"); + + const configFormats = serverInfo; //serverInfo.clientInstallations[protocol]; + const [selected, setSelected] = useState( + configFormats[configFormats.length - 1].id + ); + const [snippet, setSnippet] = useState(""); + const [openType, setOpenType] = useState(false); + + useEffect(() => { + (async () => { + const response = await httpClient.doGet( + `admin/${realm}/master/clients/${id}/installation/providers/${selected}` + ); + setSnippet(response.data!); + })(); + }, [selected]); + return ( + {}} + open={open} + toggleDialog={toggleDialog} + > + + + + + { + configFormats.find( + (configFormat) => configFormat.id === selected + )?.helpText + } + + + + + } + > + + + + + + } + > +