make updates

This commit is contained in:
Christie Molloy 2020-10-13 16:52:23 -04:00
commit 82fb002e76
58 changed files with 8915 additions and 649 deletions

View file

@ -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

75
CODE_OF_CONDUCT.md Normal file
View file

@ -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

296
CODING_GUIDELINES.md Normal file
View file

@ -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<ExampleComponentProps> = ({ message, children }: ExampleComponentProps) => (
<ReactFragment>
<div>{message}</div>
<div>{children}</div>
</<ReactFragment>>
);
```
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<{}> = () => (
<div>Example Component with no props</div>
);
```
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<IUser | null>(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<HTMLElement>(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 components 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 components 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
// Dont 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 PatternFlys 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 havent 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
// Dont increase specificity
// Dont 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 (
<>
<InfoCircleIcon
className="pf-u-mr-sm" // utility class
color="var(--pf-global--info-color--100)"
/>{" "}
{titleText}{" "}
</>
);
case "failure":
return (
<>
<InfoCircleIcon
className="pf-u-mr-sm"
color="var(--pf-global--danger-color--100)"
/>{" "}
{titleText}{" "}
</>
);
}
```
**Better way with a custom class**
```css
switch (titleStatus) {
case "success":
return (
<>
<InfoCircleIcon
className="keycloak-admin--icon--info" // use a new keycloak class
/>{" "}
{titleText}{" "}
</>
);
case "failure":
return (
<>
<InfoCircleIcon
className="keycloak-admin--icon--info"
/>{" "}
{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/)

View file

@ -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 <unpacked download folder>/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

View file

@ -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",

View file

@ -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 }) => (
<WhoAmIContextProvider>
<RealmContextProvider>
<Help>
<AlertProvider>
<ServerInfoProvider>{children}</ServerInfoProvider>
</AlertProvider>
</Help>
</RealmContextProvider>
</WhoAmIContextProvider>
);
export const App = () => {
return (
<Router>
<WhoAmIContextProvider>
<RealmContextProvider>
<Help>
<Page
header={<Header />}
isManagedSidebar
sidebar={<PageNav />}
breadcrumb={<PageBreadCrumbs />}
>
<Switch>
{routes(() => {}).map((route, i) => (
<Route key={i} {...route} exact />
))}
</Switch>
</Page>
</Help>
</RealmContextProvider>
</WhoAmIContextProvider>
</Router>
<AppContexts>
<Router>
<Page
header={<Header />}
isManagedSidebar
sidebar={<PageNav />}
breadcrumb={<PageBreadCrumbs />}
>
<Switch>
{routes(() => {}).map((route, i) => (
<Route key={i} {...route} exact />
))}
</Switch>
</Page>
</Router>
</AppContexts>
);
};

View file

@ -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<ClientRepresentation[]>();
const [filteredData, setFilteredData] = useState<ClientRepresentation[]>();
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<ClientRepresentation[]>(
`/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"
/>
<PageSection variant="light">
<DataLoader loader={loader}>
{(scopes) => (
<TableToolbar
count={scopes.data.length}
first={first}
max={max}
onNextClick={setFirst}
onPreviousClick={setFirst}
onPerPageSelect={(first, max) => {
setFirst(first);
setMax(max);
}}
toolbarItem={
<Button onClick={() => history.push("/add-client-scopes")}>
{t("createClientScope")}
</Button>
}
>
<ClientScopeList clientScopes={scopes.data} />
</TableToolbar>
)}
</DataLoader>
{!rawData && (
<div className="pf-u-text-align-center">
<Spinner />
</div>
)}
{rawData && (
<TableToolbar
inputGroupName="clientsScopeToolbarTextInput"
inputGroupPlaceholder={t("searchFor")}
inputGroupOnChange={filterData}
toolbarItem={
<Button
onClick={() =>
history.push("/client-scopes/add-client-scopes/")
}
>
{t("createClientScope")}
</Button>
}
>
<ClientScopeList clientScopes={filteredData || rawData} />
</TableToolbar>
)}
</PageSection>
</>
);

View file

@ -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"
}
}
]
}

View file

@ -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 (
<PageSection variant="light">
<Alerts />
<Form isHorizontal onSubmit={handleSubmit(save)}>
<FormGroup
label={
<>
{t("name")} <HelpItem item="clientScope.name" />
</>
}
fieldId="kc-name"
isRequired
>
<TextInput
ref={register({ required: true })}
type="text"
id="kc-name"
name="name"
/>
</FormGroup>
<FormGroup
label={
<>
{t("description")} <HelpItem item="clientScope.description" />
</>
}
fieldId="kc-description"
>
<TextInput
ref={register}
type="text"
id="kc-description"
name="description"
/>
</FormGroup>
<FormGroup
label={
<>
{t("protocol")} <HelpItem item="clientScope.protocol" />
</>
}
fieldId="kc-protocol"
>
<Controller
name="protocol"
defaultValue=""
control={control}
render={({ onChange, value }) => (
<Select
id="kc-protocol"
required
onToggle={() => isOpen(!open)}
onSelect={(_, value, isPlaceholder) => {
onChange(isPlaceholder ? "" : (value as string));
isOpen(false);
}}
selections={value}
variant={SelectVariant.single}
aria-label="Select Encryption type"
isOpen={open}
></Select>
)}
/>
</FormGroup>
<FormGroup
hasNoPaddingTop
label={
<>
{t("displayOnConsentScreen")}{" "}
<HelpItem item="clientScope.displayOnConsentScreen" />
</>
}
fieldId="kc-display.on.consent.screen"
>
<Controller
name="attributes.display_on_consent_screen"
control={control}
defaultValue={false}
render={({ onChange, value }) => (
<Switch
id="kc-display.on.consent.screen"
label={t("common:on")}
labelOff={t("common:off")}
isChecked={value}
onChange={onChange}
<>
<ViewHeader
titleKey="client-scopes:createClientScope"
subKey="client-scopes:clientScopeExplain"
/>
<PageSection variant="light">
<Form isHorizontal onSubmit={handleSubmit(save)}>
<FormGroup
label={t("name")}
labelIcon={
<HelpItem
helpText={helpText("name")}
forLabel={t("name")}
forID="kc-name"
/>
)}
/>
</FormGroup>
<FormGroup
label={
<>
{t("consentScreenText")}{" "}
<HelpItem item="clientScope.consentScreenText" />
</>
}
fieldId="kc-consent-screen-text"
>
<TextInput
ref={register}
type="text"
id="kc-consent-screen-text"
name="attributes.consent_screen_text"
/>
</FormGroup>
<FormGroup
label={
<>
{t("guiOrder")} <HelpItem item="clientScope.guiOrder" />
</>
}
fieldId="kc-gui-order"
>
<TextInput
ref={register}
type="number"
id="kc-gui-order"
name="attributes.gui_order"
/>
</FormGroup>
<ActionGroup>
<Button variant="primary" type="submit">
{t("common:save")}
</Button>
<Button variant="link">{t("common:cancel")}</Button>
</ActionGroup>
</Form>
</PageSection>
}
fieldId="kc-name"
isRequired
validated={errors.name ? "error" : "default"}
helperTextInvalid={t("common:required")}
>
<TextInput
ref={register({ required: true })}
type="text"
id="kc-name"
name="name"
/>
</FormGroup>
<FormGroup
label={t("description")}
labelIcon={
<HelpItem
helpText={helpText("description")}
forLabel={t("description")}
forID="kc-description"
/>
}
fieldId="kc-description"
>
<TextInput
ref={register}
type="text"
id="kc-description"
name="description"
/>
</FormGroup>
<FormGroup
label={t("protocol")}
labelIcon={
<HelpItem
helpText={helpText("protocol")}
forLabel="protocol"
forID="kc-protocol"
/>
}
fieldId="kc-protocol"
>
<Controller
name="protocol"
defaultValue=""
control={control}
render={({ onChange, value }) => (
<Select
toggleId="kc-protocol"
required
onToggle={() => isOpen(!open)}
onSelect={(_, value, isPlaceholder) => {
onChange(isPlaceholder ? "" : (value as string));
isOpen(false);
}}
selections={value}
variant={SelectVariant.single}
aria-label={t("selectEncryptionType")}
placeholderText={t("common:selectOne")}
isOpen={open}
>
{providers.map((option) => (
<SelectOption
selected={option === value}
key={option}
value={option}
/>
))}
</Select>
)}
/>
</FormGroup>
<FormGroup
hasNoPaddingTop
label={t("displayOnConsentScreen")}
labelIcon={
<HelpItem
helpText={helpText("displayOnConsentScreen")}
forLabel={t("displayOnConsentScreen")}
forID="kc-display.on.consent.screen"
/>
}
fieldId="kc-display.on.consent.screen"
>
<Controller
name="attributes.display_on_consent_screen"
control={control}
defaultValue={false}
render={({ onChange, value }) => (
<Switch
id="kc-display.on.consent.screen"
label={t("common:on")}
labelOff={t("common:off")}
isChecked={value}
onChange={onChange}
/>
)}
/>
</FormGroup>
<FormGroup
label={t("consentScreenText")}
labelIcon={
<HelpItem
helpText={helpText("consentScreenText")}
forLabel={t("consentScreenText")}
forID="kc-consent-screen-text"
/>
}
fieldId="kc-consent-screen-text"
>
<TextInput
ref={register}
type="text"
id="kc-consent-screen-text"
name="attributes.consent_screen_text"
/>
</FormGroup>
<FormGroup
label={t("guiOrder")}
labelIcon={
<HelpItem
helpText={helpText("guiOrder")}
forLabel={t("guiOrder")}
forID="kc-gui-order"
/>
}
fieldId="kc-gui-order"
>
<TextInput
ref={register}
type="number"
id="kc-gui-order"
name="attributes.gui_order"
/>
</FormGroup>
<ActionGroup>
<Button variant="primary" type="submit">
{t("common:save")}
</Button>
<Button variant="link" onClick={() => history.push("..")}>
{t("common:cancel")}
</Button>
</ActionGroup>
</Form>
</PageSection>
</>
);
};

View file

@ -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 (
<ListEmptyState
message={t("emptyMappers")}
instructions={t("emptyMappersInstructions")}
primaryActionText={t("emptyPrimaryAction")}
onPrimaryAction={() => {}}
/>
);
}
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 (
<TableToolbar
inputGroupName="clientsScopeToolbarTextInput"
inputGroupPlaceholder={t("mappersSearchFor")}
inputGroupOnChange={filterData}
toolbarItem={
<Dropdown
onSelect={() => setMapperAction(false)}
toggle={
<DropdownToggle
isPrimary
id="mapperAction"
onToggle={() => setMapperAction(!mapperAction)}
toggleIndicator={CaretDownIcon}
>
{t("addMapper")}
</DropdownToggle>
}
isOpen={mapperAction}
dropdownItems={[
<DropdownItem key="predefined">
{t("fromPredefinedMapper")}
</DropdownItem>,
<DropdownItem key="byConfiguration">
{t("byConfiguration")}
</DropdownItem>,
]}
/>
}
>
<Table
variant={TableVariant.compact}
cells={[t("name"), t("category"), t("type"), t("priority")]}
rows={(filteredData || data).map((cell) => {
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
);
}
},
},
]}
>
<TableHeader />
<TableBody />
</Table>
</TableToolbar>
);
};

View file

@ -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"
}
}

View file

@ -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"

View file

@ -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 (
<>
<Alerts />
<Table
variant={TableVariant.compact}
cells={[
@ -113,9 +112,12 @@ export const ClientList = ({ baseUrl, clients, refresh }: ClientListProps) => {
`/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
);
}
},
},

View file

@ -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 = () => {
}}
/>
<PageSection variant="light">
<Alerts />
<ScrollForm
sections={[
t("capabilityConfig"),

View file

@ -3,13 +3,13 @@ import { useHistory } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Button, PageSection, Spinner } from "@patternfly/react-core";
import { TableToolbar } from "../components/table-toolbar/TableToolbar";
import { ClientList } from "./ClientList";
import { HttpClientContext } from "../context/http-service/HttpClientContext";
import { KeycloakContext } from "../context/auth/KeycloakContext";
import { ClientRepresentation } from "./models/client-model";
import { RealmContext } from "../context/realm-context/RealmContext";
import { ViewHeader } from "../components/view-header/ViewHeader";
import { PaginatingTableToolbar } from "../components/table-toolbar/PaginatingTableToolbar";
export const ClientsSection = () => {
const { t } = useTranslation("clients");
@ -38,7 +38,7 @@ export const ClientsSection = () => {
useEffect(() => {
loader();
}, []);
}, [first, max]);
return (
<>
@ -48,12 +48,12 @@ export const ClientsSection = () => {
/>
<PageSection variant="light">
{!clients && (
<div style={{ textAlign: "center" }}>
<div className="pf-u-text-align-center">
<Spinner />
</div>
)}
{clients && (
<TableToolbar
<PaginatingTableToolbar
count={clients!.length}
first={first}
max={max}
@ -86,7 +86,7 @@ export const ClientsSection = () => {
refresh={loader}
baseUrl={keycloak!.authServerUrl()!}
/>
</TableToolbar>
</PaginatingTableToolbar>
)}
</PageSection>
</>

View file

@ -746,11 +746,6 @@ Object {
</tbody>
</table>
</div>
<div>
<ul
class="pf-c-alert-group pf-m-toast"
/>
</div>
</body>,
"container": <div>
<table

View file

@ -1,4 +1,4 @@
import React, { useState, useEffect, useContext } from "react";
import React, { useState } from "react";
import {
FormGroup,
Form,
@ -9,9 +9,7 @@ import {
import { useTranslation } from "react-i18next";
import { Controller, UseFormMethods } from "react-hook-form";
import { HttpClientContext } from "../../context/http-service/HttpClientContext";
import { sortProvider } from "../../util";
import { ServerInfoRepresentation } from "../models/server-info";
import { useLoginProviders } from "../../context/server-info/ServerInfoProvider";
import { ClientDescription } from "../ClientDescription";
type GeneralSettingsProps = {
@ -19,25 +17,12 @@ type GeneralSettingsProps = {
};
export const GeneralSettings = ({ form }: GeneralSettingsProps) => {
const httpClient = useContext(HttpClientContext)!;
const { t } = useTranslation();
const { errors, control, register } = form;
const { errors, control } = form;
const [providers, setProviders] = useState<string[]>([]);
const providers = useLoginProviders();
const [open, isOpen] = useState(false);
useEffect(() => {
(async () => {
const response = await httpClient.doGet<ServerInfoRepresentation>(
"/admin/serverinfo"
);
const providers = Object.entries(
response.data!.providers["login-protocol"].providers
);
setProviders(["", ...new Map(providers.sort(sortProvider)).keys()]);
})();
}, []);
return (
<Form isHorizontal>
<FormGroup
@ -63,15 +48,15 @@ export const GeneralSettings = ({ form }: GeneralSettingsProps) => {
}}
selections={value}
variant={SelectVariant.single}
aria-label="Select Encryption type"
aria-label={t("selectEncryptionType")}
placeholderText={t("common:selectOne")}
isOpen={open}
>
{providers.map((option) => (
<SelectOption
selected={option === value}
key={option}
value={option === "" ? "Select an option" : option}
isPlaceholder={option === ""}
value={option}
/>
))}
</Select>

View file

@ -37,15 +37,15 @@ export const NewClientForm = () => {
directAccessGrantsEnabled: false,
standardFlowEnabled: false,
});
const [add, Alerts] = useAlerts();
const { addAlert } = useAlerts();
const methods = useForm<ClientRepresentation>({ 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 (
<>
<Alerts />
<ViewHeader
titleKey="clients:createClient"
subKey="clients:clientsExplain"

6
src/clients/help.json Normal file
View file

@ -0,0 +1,6 @@
{
"clients-help": {
"downloadType": "this is information about the download type",
"details": "this is information about the details"
}
}

View file

@ -26,7 +26,7 @@ export const ImportForm = () => {
const form = useForm<ClientRepresentation>();
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 (
<>
<Alerts />
<ViewHeader
titleKey="clients:importClient"
subKey="clients:clientsExplain"

View file

@ -9,11 +9,17 @@
"homeURL": "Home URL",
"description": "Description",
"name": "Name",
"formatOption": "Format option",
"downloadAdaptorTitle": "Download adaptor configs",
"details": "Details",
"clientList": "Client list",
"clientSettings": "Client details",
"selectEncryptionType": "Select Encryption type",
"generalSettings": "General Settings",
"capabilityConfig": "Capability config",
"clientsExplain": "Clients are applications and services that can request authentication of a user",
"createSuccess": "Client created successfully",
"createError": "Could not create client: '{{error}}'",
"clientImportError": "Could not import client",
"clientSaveSuccess": "Client successfully updated",
"clientSaveError": "Client could not be updated:",

4
src/common-help.json Normal file
View file

@ -0,0 +1,4 @@
{
"common-help": {
}
}

View file

@ -8,10 +8,12 @@
"cancel": "Cancel",
"continue": "Continue",
"delete": "Delete",
"search": "Search",
"next": "Next",
"back": "Back",
"export": "Export",
"action": "Action",
"download": "Download",
"resourceFile": "Resource file",
"clearFile": "Clear this file",
"on": "On",
@ -19,11 +21,13 @@
"enabled": "Enabled",
"disabled": "Disabled",
"disable": "Disable",
"selectOne": "Select an option",
"signOut": "Sign out",
"manageAccount": "Manage account",
"serverInfo": "Server info",
"help": "Help",
"helpLabel": "More help for {{label}}",
"documentation": "Documentation",
"enableHelpMode": "Enable help mode",

View file

@ -1,13 +1,18 @@
import React, { useState, ReactElement } from "react";
import React, { useState, createContext, ReactNode, useContext } from "react";
import { AlertType, AlertPanel } from "./AlertPanel";
import { AlertVariant } from "@patternfly/react-core";
export function useAlerts(): [
(message: string, type?: AlertVariant) => void,
() => ReactElement,
(key: number) => void,
AlertType[]
] {
type AlertProps = {
addAlert: (message: string, variant?: AlertVariant) => void;
};
export const AlertContext = createContext<AlertProps>({
addAlert: () => {},
});
export const useAlerts = () => useContext(AlertContext);
export const AlertProvider = ({ children }: { children: ReactNode }) => {
const [alerts, setAlerts] = useState<AlertType[]>([]);
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 = () => <AlertPanel alerts={alerts} onCloseAlert={hideAlert} />;
return [add, Panel, hideAlert, alerts];
}
return (
<AlertContext.Provider value={{ addAlert }}>
<AlertPanel alerts={alerts} onCloseAlert={hideAlert} />
{children}
</AlertContext.Provider>
);
};

View file

@ -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 (
<>
<AlertPanel alerts={alerts} onCloseAlert={hide} />
<Button onClick={() => add("Hello")}>Add</Button>
</>
<AlertProvider>
<Button onClick={() => addAlert("Hello")}>Add</Button>
</AlertProvider>
);
};

View file

@ -2,164 +2,101 @@
exports[`remove alert after timeout: cleared alert 1`] = `
<WithButton>
<AlertPanel
alerts={Array []}
onCloseAlert={[Function]}
>
<AlertGroup
isToast={true}
<AlertProvider>
<AlertPanel
alerts={Array []}
onCloseAlert={[Function]}
>
<Portal
containerInfo={
<div>
<ul
class="pf-c-alert-group pf-m-toast"
/>
</div>
}
<AlertGroup
isToast={true}
>
<AlertGroupInline
isToast={true}
<Portal
containerInfo={
<div>
<ul
class="pf-c-alert-group pf-m-toast"
/>
</div>
}
>
<ul
className="pf-c-alert-group pf-m-toast"
/>
</AlertGroupInline>
</Portal>
</AlertGroup>
</AlertPanel>
<Button
onClick={[Function]}
>
<button
aria-disabled={false}
aria-label={null}
className="pf-c-button pf-m-primary"
data-ouia-component-id="OUIA-Generated-Button-primary-1"
data-ouia-component-type="PF4/Button"
data-ouia-safe={true}
disabled={false}
<AlertGroupInline
isToast={true}
>
<ul
className="pf-c-alert-group pf-m-toast"
/>
</AlertGroupInline>
</Portal>
</AlertGroup>
</AlertPanel>
<Button
onClick={[Function]}
type="button"
>
Add
</button>
</Button>
<button
aria-disabled={false}
aria-label={null}
className="pf-c-button pf-m-primary"
data-ouia-component-id="OUIA-Generated-Button-primary-1"
data-ouia-component-type="PF4/Button"
data-ouia-safe={true}
disabled={false}
onClick={[Function]}
type="button"
>
Add
</button>
</Button>
</AlertProvider>
</WithButton>
`;
exports[`remove alert after timeout: with alert 1`] = `
<WithButton>
<AlertPanel
alerts={Array []}
onCloseAlert={[Function]}
>
<AlertGroup
isToast={true}
<AlertProvider>
<AlertPanel
alerts={Array []}
onCloseAlert={[Function]}
>
<Portal
containerInfo={
<div>
<ul
class="pf-c-alert-group pf-m-toast"
>
<li>
<div
aria-atomic="false"
aria-label="Default Alert"
aria-live="polite"
class="pf-c-alert"
data-ouia-component-id="OUIA-Generated-Alert-default-1"
data-ouia-component-type="PF4/Alert"
data-ouia-safe="true"
>
<div
class="pf-c-alert__icon"
>
<svg
aria-hidden="true"
fill="currentColor"
height="1em"
role="img"
style="vertical-align: -0.125em;"
viewBox="0 0 896 1024"
width="1em"
>
<path
d="M448,0 C465.333333,0 480.333333,6.33333333 493,19 C505.666667,31.6666667 512,46.6666667 512,64 L512,106 L514.23,106.45 C587.89,121.39 648.48,157.24 696,214 C744,271.333333 768,338.666667 768,416 C768,500 780,568.666667 804,622 C818.666667,652.666667 841.333333,684 872,716 C873.773676,718.829136 875.780658,721.505113 878,724 C890,737.333333 896,752.333333 896,769 C896,785.666667 890,800.333333 878,813 C866,825.666667 850.666667,832 832,832 L63.3,832 C44.9533333,831.84 29.8533333,825.506667 18,813 C6,800.333333 0,785.666667 0,769 C0,752.333333 6,737.333333 18,724 L24,716 L25.06,714.9 C55.1933333,683.28 77.5066667,652.313333 92,622 C116,568.666667 128,500 128,416 C128,338.666667 152,271.333333 200,214 C248,156.666667 309.333333,120.666667 384,106 L384,63.31 C384.166667,46.27 390.5,31.5 403,19 C415.666667,6.33333333 430.666667,0 448,0 Z M576,896 L576,897.08 C575.74,932.6 563.073333,962.573333 538,987 C512.666667,1011.66667 482.666667,1024 448,1024 C413.333333,1024 383.333333,1011.66667 358,987 C332.666667,962.333333 320,932 320,896 L576,896 Z"
/>
</svg>
</div>
<h4
class="pf-c-alert__title"
>
<span
class="pf-u-screen-reader"
/>
Hello
</h4>
<div
class="pf-c-alert__action"
>
<button
aria-disabled="false"
aria-label="Close alert: Hello"
class="pf-c-button pf-m-plain"
data-ouia-component-id="OUIA-Generated-Button-plain-1"
data-ouia-component-type="PF4/Button"
data-ouia-safe="true"
title="Hello"
type="button"
>
<svg
aria-hidden="true"
fill="currentColor"
height="1em"
role="img"
style="vertical-align: -0.125em;"
viewBox="0 0 352 512"
width="1em"
>
<path
d="M242.72 256l100.07-100.07c12.28-12.28 12.28-32.19 0-44.48l-22.24-22.24c-12.28-12.28-32.19-12.28-44.48 0L176 189.28 75.93 89.21c-12.28-12.28-32.19-12.28-44.48 0L9.21 111.45c-12.28 12.28-12.28 32.19 0 44.48L109.28 256 9.21 356.07c-12.28 12.28-12.28 32.19 0 44.48l22.24 22.24c12.28 12.28 32.2 12.28 44.48 0L176 322.72l100.07 100.07c12.28 12.28 32.2 12.28 44.48 0l22.24-22.24c12.28-12.28 12.28-32.19 0-44.48L242.72 256z"
/>
</svg>
</button>
</div>
</div>
</li>
</ul>
</div>
}
<AlertGroup
isToast={true}
>
<AlertGroupInline
isToast={true}
<Portal
containerInfo={
<div>
<ul
class="pf-c-alert-group pf-m-toast"
/>
</div>
}
>
<ul
className="pf-c-alert-group pf-m-toast"
/>
</AlertGroupInline>
</Portal>
</AlertGroup>
</AlertPanel>
<Button
onClick={[Function]}
>
<button
aria-disabled={false}
aria-label={null}
className="pf-c-button pf-m-primary"
data-ouia-component-id="OUIA-Generated-Button-primary-1"
data-ouia-component-type="PF4/Button"
data-ouia-safe={true}
disabled={false}
<AlertGroupInline
isToast={true}
>
<ul
className="pf-c-alert-group pf-m-toast"
/>
</AlertGroupInline>
</Portal>
</AlertGroup>
</AlertPanel>
<Button
onClick={[Function]}
type="button"
>
Add
</button>
</Button>
<button
aria-disabled={false}
aria-label={null}
className="pf-c-button pf-m-primary"
data-ouia-component-id="OUIA-Generated-Button-primary-1"
data-ouia-component-type="PF4/Button"
data-ouia-safe={true}
disabled={false}
onClick={[Function]}
type="button"
>
Add
</button>
</Button>
</AlertProvider>
</WithButton>
`;

View file

@ -33,7 +33,7 @@ export function DataLoader<T>(props: DataLoaderProps<T>) {
return props.children;
}
return (
<div style={{ textAlign: "center" }}>
<div className="pf-u-text-align-center">
<Spinner />
</div>
);

View file

@ -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 = () => (
<DownloadDialog {...props} open={show} toggleDialog={toggleDialog} />
);
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<string>(
`admin/${realm}/master/clients/${id}/installation/providers/${selected}`
);
setSnippet(response.data!);
})();
}, [selected]);
return (
<ConfirmDialogModal
titleKey={t("clients:downloadAdaptorTitle")}
continueButtonLabel={t("download")}
onConfirm={() => {}}
open={open}
toggleDialog={toggleDialog}
>
<Form>
<Stack hasGutter>
<StackItem>
<Alert
id={id}
title={t("clients:description")}
variant={AlertVariant.info}
isInline
>
{
configFormats.find(
(configFormat) => configFormat.id === selected
)?.helpText
}
</Alert>
</StackItem>
<StackItem>
<FormGroup
fieldId="type"
label={t("clients:formatOption")}
labelIcon={
<HelpItem
helpText={t("clients-help:downloadType")}
forLabel={t("clients:formatOption")}
forID="type"
/>
}
>
<Select
toggleId="type"
isOpen={openType}
onToggle={() => {
setOpenType(!openType);
}}
variant={SelectVariant.single}
value={selected}
selections={selected}
onSelect={(_, value) => {
setSelected(value as string);
setOpenType(false);
}}
aria-label="Select Input"
>
{configFormats.map((configFormat) => (
<SelectOption
key={configFormat.id}
value={configFormat.id}
isSelected={selected === configFormat.id}
>
{configFormat.displayType}
</SelectOption>
))}
</Select>
</FormGroup>
</StackItem>
<StackItem isFilled>
<FormGroup
fieldId="details"
label={t("clients:details")}
labelIcon={
<HelpItem
helpText={t("clients-help:details")}
forLabel={t("clients:details")}
forID=""
/>
}
>
<TextArea
id="details"
readOnly
rows={12}
resizeOrientation="vertical"
value={snippet}
aria-label="text area example"
/>
</FormGroup>
</StackItem>
</Stack>
</Form>
</ConfirmDialogModal>
);
};

View file

@ -1,24 +1,31 @@
import React, { useContext } from "react";
import { Tooltip } from "@patternfly/react-core";
import { Popover } from "@patternfly/react-core";
import { HelpIcon } from "@patternfly/react-icons";
import { useTranslation } from "react-i18next";
import { HelpContext } from "./HelpHeader";
type HelpItemProps = {
item: string;
helpText: string;
forLabel: string;
forID: string;
};
export const HelpItem = ({ item }: HelpItemProps) => {
export const HelpItem = ({ helpText, forLabel, forID }: HelpItemProps) => {
const { t } = useTranslation();
const { enabled } = useContext(HelpContext);
return (
<>
{enabled && (
<Tooltip position="right" content={t(`help:${item}`)}>
<span id={item} data-testid={item}>
<HelpIcon />
</span>
</Tooltip>
<Popover bodyContent={helpText}>
<button
aria-label={t(`helpLabel`, { label: forLabel })}
onClick={(e) => e.preventDefault()}
aria-describedby={forID}
className="pf-c-form__group-label-help"
>
<HelpIcon noVerticalAlign />
</button>
</Popover>
)}
</>
);

View file

@ -5,7 +5,9 @@ import { HelpItem } from "../HelpItem";
describe("<HelpItem />", () => {
it("render", () => {
const comp = render(<HelpItem item="storybook" />);
const comp = render(
<HelpItem helpText="storybook" forLabel="storybook" forID="placeholder" />
);
expect(comp.asFragment()).toMatchSnapshot();
});
});

View file

@ -2,17 +2,16 @@
exports[`<HelpItem /> render 1`] = `
<DocumentFragment>
<span
aria-describedby="pf-tooltip-1"
data-testid="storybook"
id="storybook"
<button
aria-describedby="placeholder"
aria-label="helpLabel"
class="pf-c-form__group-label-help"
>
<svg
aria-hidden="true"
fill="currentColor"
height="1em"
role="img"
style="vertical-align: -0.125em;"
viewBox="0 0 1024 1024"
width="1em"
>
@ -20,6 +19,6 @@ exports[`<HelpItem /> render 1`] = `
d="M521.3,576 C627.5,576 713.7,502 713.7,413.7 C713.7,325.4 627.6,253.6 521.3,253.6 C366,253.6 334.5,337.7 329.2,407.2 C329.2,414.3 335.2,416 343.5,416 L445,416 C450.5,416 458,415.5 460.8,406.5 C460.8,362.6 582.9,357.1 582.9,413.6 C582.9,441.9 556.2,470.9 521.3,473 C486.4,475.1 447.3,479.8 447.3,521.7 L447.3,553.8 C447.3,570.8 456.1,576 472,576 C487.9,576 521.3,576 521.3,576 M575.3,751.3 L575.3,655.3 C575.313862,651.055109 573.620137,646.982962 570.6,644 C567.638831,640.947672 563.552355,639.247987 559.3,639.29884 L463.3,639.29884 C459.055109,639.286138 454.982962,640.979863 452,644 C448.947672,646.961169 447.247987,651.047645 447.29884,655.3 L447.29884,751.3 C447.286138,755.544891 448.979863,759.617038 452,762.6 C454.961169,765.652328 459.047645,767.352013 463.3,767.30116 L559.3,767.30116 C563.544891,767.313862 567.617038,765.620137 570.6,762.6 C573.659349,759.643612 575.360354,755.553963 575.3,751.3 M512,896 C300.2,896 128,723.9 128,512 C128,300.3 300.2,128 512,128 C723.8,128 896,300.2 896,512 C896,723.8 723.7,896 512,896 M512.1,0 C229.7,0 0,229.8 0,512 C0,794.2 229.8,1024 512.1,1024 C794.4,1024 1024,794.3 1024,512 C1024,229.7 794.4,0 512.1,0"
/>
</svg>
</span>
</button>
</DocumentFragment>
`;

View file

@ -5,21 +5,31 @@ import {
EmptyStateBody,
Title,
Button,
ButtonVariant,
EmptyStateSecondaryActions,
} from "@patternfly/react-core";
import { PlusCircleIcon } from "@patternfly/react-icons";
export type Action = {
text: string;
type?: ButtonVariant;
onClick: MouseEventHandler<HTMLButtonElement>;
};
export type ListEmptyStateProps = {
message: string;
instructions: string;
primaryActionText: string;
onPrimaryAction: MouseEventHandler<HTMLButtonElement>;
secondaryActions?: Action[];
};
export const ListEmptyState = ({
message,
instructions,
primaryActionText,
onPrimaryAction,
primaryActionText,
secondaryActions,
}: ListEmptyStateProps) => {
return (
<>
@ -32,6 +42,19 @@ export const ListEmptyState = ({
<Button variant="primary" onClick={onPrimaryAction}>
{primaryActionText}
</Button>
{secondaryActions && (
<EmptyStateSecondaryActions>
{secondaryActions.map((action) => (
<Button
key={action.text}
variant={action.type || ButtonVariant.primary}
onClick={action.onClick}
>
{action.text}
</Button>
))}
</EmptyStateSecondaryActions>
)}
</EmptyState>
</>
);

View file

@ -8,8 +8,9 @@ describe("<ListEmptyState />", () => {
<ListEmptyState
message="No things"
instructions="You haven't created any things for this list."
primaryActionText="Add a thing"
primaryActionText="Add it now!"
onPrimaryAction={() => {}}
secondaryActions={[{ text: "Add a thing", onClick: () => {} }]}
/>
);
expect(comp.asFragment()).toMatchSnapshot();

View file

@ -40,8 +40,22 @@ exports[`<ListEmptyState /> render 1`] = `
data-ouia-safe="true"
type="button"
>
Add a thing
Add it now!
</button>
<div
class="pf-c-empty-state__secondary"
>
<button
aria-disabled="false"
class="pf-c-button pf-m-primary"
data-ouia-component-id="OUIA-Generated-Button-primary-2"
data-ouia-component-type="PF4/Button"
data-ouia-safe="true"
type="button"
>
Add a thing
</button>
</div>
</div>
</div>
</DocumentFragment>

View file

@ -0,0 +1,77 @@
import React, { MouseEventHandler } from "react";
import {
Pagination,
ToggleTemplateProps,
ToolbarItem,
} from "@patternfly/react-core";
import { TableToolbar } from "./TableToolbar";
type TableToolbarProps = {
count: number;
first: number;
max: number;
onNextClick: (page: number) => void;
onPreviousClick: (page: number) => void;
onPerPageSelect: (max: number, first: number) => void;
toolbarItem?: React.ReactNode;
children: React.ReactNode;
inputGroupName?: string;
inputGroupPlaceholder?: string;
inputGroupOnChange?: (
newInput: string,
event: React.FormEvent<HTMLInputElement>
) => void;
inputGroupOnClick?: MouseEventHandler;
};
export const PaginatingTableToolbar = ({
count,
first,
max,
onNextClick,
onPreviousClick,
onPerPageSelect,
toolbarItem,
children,
inputGroupName,
inputGroupPlaceholder,
inputGroupOnChange,
inputGroupOnClick,
}: TableToolbarProps) => {
const page = first / max;
const pagination = (variant: "top" | "bottom" = "top") => (
<Pagination
isCompact
toggleTemplate={({ firstIndex, lastIndex }: ToggleTemplateProps) => (
<b>
{firstIndex} - {lastIndex}
</b>
)}
itemCount={count + page * max + (count <= max ? 1 : 0)}
page={page + 1}
perPage={max}
onNextClick={(_, p) => onNextClick((p - 1) * max)}
onPreviousClick={(_, p) => onPreviousClick((p - 1) * max)}
onPerPageSelect={(_, m, f) => onPerPageSelect(f, m)}
variant={variant}
/>
);
return (
<TableToolbar
toolbarItem={
<>
{toolbarItem}
<ToolbarItem variant="pagination">{pagination()}</ToolbarItem>
</>
}
toolbarItemFooter={<ToolbarItem>{pagination("bottom")}</ToolbarItem>}
inputGroupName={inputGroupName}
inputGroupPlaceholder={inputGroupPlaceholder}
inputGroupOnChange={inputGroupOnChange}
inputGroupOnClick={inputGroupOnClick}
>
{children}
</TableToolbar>
);
};

View file

@ -1,6 +1,5 @@
import React, { MouseEventHandler } from "react";
import React, { MouseEventHandler, ReactNode } from "react";
import {
ToggleTemplateProps,
Toolbar,
ToolbarContent,
ToolbarItem,
@ -8,19 +7,13 @@ import {
TextInput,
Button,
ButtonVariant,
Pagination,
} from "@patternfly/react-core";
import { SearchIcon } from "@patternfly/react-icons";
import { useTranslation } from "react-i18next";
type TableToolbarProps = {
count: number;
first: number;
max: number;
onNextClick: (page: number) => void;
onPreviousClick: (page: number) => void;
onPerPageSelect: (max: number, first: number) => void;
toolbarItem?: React.ReactNode;
toolbarItem?: ReactNode;
toolbarItemFooter?: ReactNode;
children: React.ReactNode;
inputGroupName?: string;
inputGroupPlaceholder?: string;
@ -32,39 +25,15 @@ type TableToolbarProps = {
};
export const TableToolbar = ({
count,
first,
max,
onNextClick,
onPreviousClick,
onPerPageSelect,
toolbarItem,
toolbarItemFooter,
children,
inputGroupName,
inputGroupPlaceholder,
inputGroupOnChange,
inputGroupOnClick,
}: TableToolbarProps) => {
const { t } = useTranslation("groups");
const page = first / max;
const pagination = (variant: "top" | "bottom" = "top") => (
<Pagination
isCompact
toggleTemplate={({ firstIndex, lastIndex }: ToggleTemplateProps) => (
<b>
{firstIndex} - {lastIndex}
</b>
)}
itemCount={count + page * max + (count <= max ? 1 : 0)}
page={page + 1}
perPage={max}
onNextClick={(_, p) => onNextClick((p - 1) * max)}
onPreviousClick={(_, p) => onPreviousClick((p - 1) * max)}
onPerPageSelect={(_, m, f) => onPerPageSelect(f, m)}
variant={variant}
/>
);
const { t } = useTranslation();
return (
<>
<Toolbar>
@ -77,13 +46,13 @@ export const TableToolbar = ({
name={inputGroupName}
id={inputGroupName}
type="search"
aria-label={t("Search")}
aria-label={t("search")}
placeholder={inputGroupPlaceholder}
onChange={inputGroupOnChange}
/>
<Button
variant={ButtonVariant.control}
aria-label={t("Search")}
aria-label={t("search")}
onClick={inputGroupOnClick}
>
<SearchIcon />
@ -93,13 +62,10 @@ export const TableToolbar = ({
)}
</React.Fragment>
{toolbarItem}
<ToolbarItem variant="pagination">{pagination()}</ToolbarItem>
</ToolbarContent>
</Toolbar>
{children}
<Toolbar>
<ToolbarItem>{pagination("bottom")}</ToolbarItem>
</Toolbar>
<Toolbar>{toolbarItemFooter}</Toolbar>
</>
);
};

View file

@ -12,15 +12,18 @@ import {
ToolbarItem,
Badge,
Select,
ButtonProps,
} from "@patternfly/react-core";
import { HelpContext } from "../help-enabler/HelpHeader";
import { useTranslation } from "react-i18next";
import { PageBreadCrumbs } from "../bread-crumb/PageBreadCrumbs";
import { ExternalLink } from "../external-link/ExternalLink";
export type ViewHeaderProps = {
titleKey: string;
badge?: string;
subKey: string;
subKeyLinkProps?: ButtonProps;
selectItems?: ReactElement[];
isEnabled?: boolean;
onSelect?: (value: string) => void;
@ -31,6 +34,7 @@ export const ViewHeader = ({
titleKey,
badge,
subKey,
subKeyLinkProps,
selectItems,
isEnabled = true,
onSelect,
@ -98,7 +102,16 @@ export const ViewHeader = ({
</Level>
{enabled && (
<TextContent>
<Text>{t(subKey)}</Text>
<Text>
{t(subKey)}
{subKeyLinkProps && (
<ExternalLink
{...subKeyLinkProps}
isInline
className="pf-u-ml-md"
/>
)}
</Text>
</TextContent>
)}
</PageSection>

View file

@ -0,0 +1,37 @@
import React, { createContext, ReactNode, useContext } from "react";
import { ServerInfoRepresentation } from "./server-info";
import { HttpClientContext } from "../http-service/HttpClientContext";
import { sortProvider } from "../../util";
import { DataLoader } from "../../components/data-loader/DataLoader";
export const ServerInfoContext = createContext<ServerInfoRepresentation>(
{} as ServerInfoRepresentation
);
export const useServerInfo = () => useContext(ServerInfoContext);
export const useLoginProviders = () => {
const serverInfo = Object.entries(
useServerInfo().providers["login-protocol"].providers
);
return [...new Map(serverInfo.sort(sortProvider)).keys()];
};
export const ServerInfoProvider = ({ children }: { children: ReactNode }) => {
const httpClient = useContext(HttpClientContext)!;
const loader = async () => {
const response = await httpClient.doGet<ServerInfoRepresentation>(
"/admin/serverinfo"
);
return response.data!;
};
return (
<DataLoader loader={loader}>
{(serverInfo) => (
<ServerInfoContext.Provider value={serverInfo.data}>
{children}
</ServerInfoContext.Provider>
)}
</DataLoader>
);
};

File diff suppressed because it is too large Load diff

View file

@ -20,6 +20,7 @@ type GroupsCreateModalProps = {
setIsCreateModalOpen: (isCreateModalOpen: boolean) => void;
createGroupName: string;
setCreateGroupName: (createGroupName: string) => void;
refresh: () => void;
};
export const GroupsCreateModal = ({
@ -28,11 +29,12 @@ export const GroupsCreateModal = ({
setIsCreateModalOpen,
createGroupName,
setCreateGroupName,
refresh
}: GroupsCreateModalProps) => {
const { t } = useTranslation("groups");
const httpClient = useContext(HttpClientContext)!;
const { realm } = useContext(RealmContext);
const [add, Alerts] = useAlerts();
const { addAlert } = useAlerts();
const form = useForm();
const { register, errors } = form;
@ -43,21 +45,21 @@ export const GroupsCreateModal = ({
const submitForm = async () => {
if (await form.trigger()) {
try {
httpClient.doPost(`/admin/realms/${realm}/groups`, {
await httpClient.doPost(`/admin/realms/${realm}/groups`, {
name: createGroupName,
});
setIsCreateModalOpen(false);
setCreateGroupName("");
add(t("groupCreated"), AlertVariant.success);
refresh();
addAlert(t("groupCreated"), AlertVariant.success);
} catch (error) {
add(`${t("couldNotCreateGroup")} ': '${error}'`, AlertVariant.danger);
addAlert(`${t("couldNotCreateGroup")} ': '${error}'`, AlertVariant.danger);
}
}
};
return (
<React.Fragment>
<Alerts />
<>
<Modal
variant={ModalVariant.small}
title={t("createAGroup")}
@ -88,6 +90,6 @@ export const GroupsCreateModal = ({
</FormGroup>
</Form>
</Modal>
</React.Fragment>
</>
);
};

View file

@ -15,18 +15,22 @@ import { useAlerts } from "../components/alert/Alerts";
type GroupsListProps = {
list?: GroupRepresentation[];
refresh: () => void;
};
export const GroupsList = ({ list }: GroupsListProps) => {
type FormattedData = {
cells: JSX.Element[];
selected: boolean;
};
export const GroupsList = ({ list, refresh }: GroupsListProps) => {
const { t } = useTranslation("groups");
const httpClient = useContext(HttpClientContext)!;
const columnGroupName: keyof GroupRepresentation = "name";
const columnGroupNumber: keyof GroupRepresentation = "membersLength";
const { realm } = useContext(RealmContext);
const [add, Alerts] = useAlerts();
const [formattedData, setFormattedData] = useState([
{ cells: [<Button key="0">Test</Button>], selected: false },
]);
const { addAlert } = useAlerts();
const [formattedData, setFormattedData] = useState<FormattedData[]>([]);
const formatData = (data: GroupRepresentation[]) =>
data.map((group: { [key: string]: any }, index) => {
@ -38,7 +42,7 @@ export const GroupsList = ({ list }: GroupsListProps) => {
{groupName}
</Button>,
<div className="keycloak-admin--groups__member-count" key={index}>
<UsersIcon />
<UsersIcon key={`user-icon-${index}`} />
{groupNumber}
</div>,
],
@ -51,7 +55,7 @@ export const GroupsList = ({ list }: GroupsListProps) => {
}, [list]);
function onSelect(
event: React.FormEvent<HTMLInputElement>,
_: React.FormEvent<HTMLInputElement>,
isSelected: boolean,
rowId: number
) {
@ -76,22 +80,22 @@ export const GroupsList = ({ list }: GroupsListProps) => {
},
{
title: t("common:Delete"),
onClick: (_: React.MouseEvent<Element, MouseEvent>, rowId: number) => {
onClick: async (_: React.MouseEvent<Element, MouseEvent>, rowId: number) => {
try {
httpClient.doDelete(
await httpClient.doDelete(
`/admin/realms/${realm}/groups/${list![rowId].id}`
);
add(t("Group deleted"), AlertVariant.success);
refresh();
addAlert(t("Group deleted"), AlertVariant.success);
} catch (error) {
add(`${t("clientDeleteError")} ${error}`, AlertVariant.danger);
addAlert(`${t("clientDeleteError")} ${error}`, AlertVariant.danger);
}
},
},
];
return (
<React.Fragment>
<Alerts />
<>
{formattedData && (
<Table
actions={actions}
@ -106,6 +110,6 @@ export const GroupsList = ({ list }: GroupsListProps) => {
<TableBody />
</Table>
)}
</React.Fragment>
</>
);
};

View file

@ -1,4 +1,4 @@
import React, { useContext, useState, useEffect } from "react";
import React, { useContext, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { HttpClientContext } from "../context/http-service/HttpClientContext";
import { GroupsList } from "./GroupsList";
@ -17,6 +17,7 @@ import {
KebabToggle,
PageSection,
PageSectionVariants,
Spinner,
ToolbarItem,
} from "@patternfly/react-core";
import "./GroupsSection.css";
@ -24,22 +25,18 @@ import "./GroupsSection.css";
export const GroupsSection = () => {
const { t } = useTranslation("groups");
const httpClient = useContext(HttpClientContext)!;
const [rawData, setRawData] = useState([{}]);
const [filteredData, setFilteredData] = useState([{}]);
const [max, setMax] = useState(10);
const [first, setFirst] = useState(0);
const [rawData, setRawData] = useState<{ [key: string]: any }[]>();
const [filteredData, setFilteredData] = useState<object[]>();
const [isKebabOpen, setIsKebabOpen] = useState(false);
const [createGroupName, setCreateGroupName] = useState("");
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const columnID: keyof GroupRepresentation = "id";
const membersLength: keyof GroupRepresentation = "membersLength";
const columnGroupName: keyof GroupRepresentation = "name";
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const loader = async () => {
const groups = await httpClient.doGet<ServerGroupsArrayRepresentation[]>(
"/admin/realms/master/groups",
{ params: { first, max } }
"/admin/realms/master/groups"
);
const groupsData = groups.data!;
@ -62,24 +59,19 @@ export const GroupsSection = () => {
return object;
}
);
return updatedObject;
setRawData(updatedObject);
};
useEffect(() => {
loader().then((data: GroupRepresentation[]) => {
data && setRawData(data);
setFilteredData(data);
});
}, [createGroupName]);
loader();
}, []);
// Filter groups
const filterGroups = (newInput: string) => {
const localRowData: object[] = [];
rawData.forEach(function (obj: { [key: string]: string }) {
const localRowData = rawData!.filter((obj: { [key: string]: string }) => {
const groupName = obj[columnGroupName];
if (groupName.toLowerCase().includes(newInput.toLowerCase())) {
localRowData.push(obj);
}
return groupName.toLowerCase().includes(newInput.toLowerCase());
});
setFilteredData(localRowData);
};
@ -101,53 +93,51 @@ export const GroupsSection = () => {
<React.Fragment>
<ViewHeader titleKey="groups:groups" subKey="groups:groupsDescription" />
<PageSection variant={PageSectionVariants.light}>
<TableToolbar
count={10}
first={first}
max={max}
onNextClick={setFirst}
onPreviousClick={setFirst}
onPerPageSelect={(f, m) => {
setFirst(f);
setMax(m);
}}
inputGroupName="groupsToolbarTextInput"
inputGroupPlaceholder="Search groups"
inputGroupOnChange={filterGroups}
toolbarItem={
<>
<ToolbarItem>
<Button variant="primary" onClick={() => handleModalToggle()}>
{t("createGroup")}
</Button>
</ToolbarItem>
<ToolbarItem>
<Dropdown
onSelect={onKebabSelect}
toggle={<KebabToggle onToggle={onKebabToggle} />}
isOpen={isKebabOpen}
isPlain
dropdownItems={[
<DropdownItem key="action" component="button">
{t("delete")}
</DropdownItem>,
]}
/>
</ToolbarItem>
</>
}
>
{rawData && filteredData && (
<GroupsList list={filteredData ? filteredData : rawData} />
)}
</TableToolbar>
<GroupsCreateModal
isCreateModalOpen={isCreateModalOpen}
handleModalToggle={handleModalToggle}
setIsCreateModalOpen={setIsCreateModalOpen}
createGroupName={createGroupName}
setCreateGroupName={setCreateGroupName}
/>
{rawData ? (
<>
<TableToolbar
inputGroupName="groupsToolbarTextInput"
inputGroupPlaceholder={t("searchGroups")}
inputGroupOnChange={filterGroups}
toolbarItem={
<>
<ToolbarItem>
<Button variant="primary" onClick={() => handleModalToggle()}>
{t("createGroup")}
</Button>
</ToolbarItem>
<ToolbarItem>
<Dropdown
onSelect={onKebabSelect}
toggle={<KebabToggle onToggle={onKebabToggle} />}
isOpen={isKebabOpen}
isPlain
dropdownItems={[
<DropdownItem key="action" component="button">
{t("delete")}
</DropdownItem>,
]}
/>
</ToolbarItem>
</>
}
>
<GroupsList list={filteredData || rawData} refresh={loader}/>
</TableToolbar>
<GroupsCreateModal
isCreateModalOpen={isCreateModalOpen}
handleModalToggle={handleModalToggle}
setIsCreateModalOpen={setIsCreateModalOpen}
createGroupName={createGroupName}
setCreateGroupName={setCreateGroupName}
refresh={loader}
/>
</>
) : (
<div className="pf-u-text-align-center">
<Spinner />
</div>
)}
</PageSection>
</React.Fragment>
);

View file

@ -1,13 +0,0 @@
{
"help": {
"storybook": "Sometimes you need some help and it's nice when the app does that",
"clientScope": {
"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"
}
}
}

View file

@ -3,15 +3,18 @@ import { initReactI18next } from "react-i18next";
// import backend from "i18next-http-backend";
import common from "./common-messages.json";
import help from "./common-help.json";
import clients from "./clients/messages.json";
import clientsHelp from "./clients/help.json";
import clientScopes from "./client-scopes/messages.json";
import clientScopesHelp from "./client-scopes/help.json";
import groups from "./groups/messages.json";
import realm from "./realm/messages.json";
import roles from "./realm-roles/messages.json";
import users from "./user/messages.json";
import sessions from "./sessions/messages.json";
import events from "./events/messages.json";
import help from "./help.json";
import storybook from "./stories/messages.json";
const initOptions = {
defaultNS: "common",
@ -20,7 +23,9 @@ const initOptions = {
...common,
...help,
...clients,
...clientsHelp,
...clientScopes,
...clientScopesHelp,
...groups,
...realm,
...roles,
@ -28,6 +33,7 @@ const initOptions = {
...users,
...sessions,
...events,
...storybook,
},
},
lng: "en",

View file

@ -1,16 +1,7 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import React, { useState, useContext, useEffect } from "react";
import React, { useContext } from "react";
import { useHistory } from "react-router-dom";
import { useTranslation } from "react-i18next";
import {
Button,
Divider,
Page,
PageSection,
PageSectionVariants,
Text,
TextContent,
} from "@patternfly/react-core";
import { Button, PageSection } from "@patternfly/react-core";
import { DataLoader } from "../components/data-loader/DataLoader";
import { TableToolbar } from "../components/table-toolbar/TableToolbar";
@ -18,61 +9,39 @@ import { HttpClientContext } from "../context/http-service/HttpClientContext";
import { RoleRepresentation } from "../model/role-model";
import { RolesList } from "./RoleList";
import { RealmContext } from "../context/realm-context/RealmContext";
import { ViewHeader } from "../components/view-header/ViewHeader";
export const RealmRolesSection = () => {
const { t } = useTranslation("roles");
const history = useHistory();
const [max, setMax] = useState(10);
const [, setRoles] = useState([] as RoleRepresentation[]);
const [first, setFirst] = useState(0);
const httpClient = useContext(HttpClientContext)!;
const { realm } = useContext(RealmContext);
const loader = async () => {
return await httpClient
.doGet(`/admin/realms/${realm}/roles`)
.then((r) => r.data as RoleRepresentation[]);
const result = await httpClient.doGet<RoleRepresentation[]>(
`/admin/realms/${realm}/roles`
);
return result.data;
};
useEffect(() => {
loader().then((result) => setRoles(result || []));
}, []);
return (
<DataLoader loader={loader}>
{(roles) => (
<>
<PageSection variant="light">
<TextContent>
<Text component="h1">Realm roles</Text>
<Text component="p">{t("roleExplain")}</Text>
</TextContent>
</PageSection>
<Divider component="li" key={1} />
<PageSection padding={{ default: "noPadding" }}>
<TableToolbar
count={roles.data.length}
first={first}
max={max}
onNextClick={setFirst}
onPreviousClick={setFirst}
onPerPageSelect={(f, m) => {
setFirst(f);
setMax(m);
}}
toolbarItem={
<>
<Button onClick={() => history.push("/add-role")}>
{t("createRole")}
</Button>
</>
}
>
<RolesList roles={roles.data} />
</TableToolbar>
</PageSection>
</>
)}
</DataLoader>
<>
<ViewHeader titleKey="roles:title" subKey="roles:roleExplain" />
<PageSection padding={{ default: "noPadding" }}>
<TableToolbar
toolbarItem={
<>
<Button onClick={() => history.push("/add-role")}>
{t("createRole")}
</Button>
</>
}
>
<DataLoader loader={loader}>
{(roles) => <RolesList roles={roles.data} />}
</DataLoader>
</TableToolbar>
</PageSection>
</>
);
};

View file

@ -24,7 +24,7 @@ import { RealmContext } from "../../context/realm-context/RealmContext";
export const NewRoleForm = () => {
const { t } = useTranslation("roles");
const httpClient = useContext(HttpClientContext)!;
const [addAlert, Alerts] = useAlerts();
const { addAlert } = useAlerts();
const { realm } = useContext(RealmContext);
const { register, control, errors, handleSubmit } = useForm<
@ -42,7 +42,6 @@ export const NewRoleForm = () => {
return (
<>
<Alerts />
<PageSection variant="light">
<TextContent>
<Text component="h1">{t("createRole")}</Text>

View file

@ -1,5 +1,6 @@
{
"roles": {
"title": "Realm roles",
"createRole": "Create role",
"importRole": "Import role",
"roleID": "Role ID",

View file

@ -21,7 +21,7 @@ import { ViewHeader } from "../../components/view-header/ViewHeader";
export const NewRealmForm = () => {
const { t } = useTranslation("realm");
const httpClient = useContext(HttpClientContext)!;
const [add, Alerts] = useAlerts();
const { addAlert } = useAlerts();
const { register, handleSubmit, setValue, control } = useForm<
RealmRepresentation
@ -39,15 +39,17 @@ export const NewRealmForm = () => {
const save = async (realm: RealmRepresentation) => {
try {
await httpClient.doPost("/admin/realms", realm);
add(t("Realm created"), AlertVariant.success);
addAlert(t("Realm created"), AlertVariant.success);
} catch (error) {
add(`${t("Could not create realm:")} '${error}'`, AlertVariant.danger);
addAlert(
`${t("Could not create realm:")} '${error}'`,
AlertVariant.danger
);
}
};
return (
<>
<Alerts />
<ViewHeader titleKey="realm:createRealm" subKey="realm:realmExplain" />
<PageSection variant="light">
<Form isHorizontal onSubmit={handleSubmit(save)}>

View file

@ -47,10 +47,10 @@ export const routes = (t: TFunction) => [
{
path: "/client-scopes",
component: ClientScopesSection,
breadcrumb: t("clientScopeList"),
breadcrumb: t("client-scopes:clientScopeList"),
},
{
path: "/add-client-scopes",
path: "/client-scopes/add-client-scopes",
component: NewClientScopeForm,
breadcrumb: t("client-scopes:createClientScope"),
},

View file

@ -3,7 +3,7 @@ import { AlertVariant, Button } from "@patternfly/react-core";
import { Meta } from "@storybook/react";
import { AlertPanel } from "../components/alert/AlertPanel";
import { useAlerts } from "../components/alert/Alerts";
import { AlertProvider, useAlerts } from "../components/alert/Alerts";
export default {
title: "Alert Panel",
@ -17,11 +17,12 @@ export const Api = () => (
/>
);
export const AddAlert = () => {
const [add, Alerts] = useAlerts();
const { addAlert } = useAlerts();
return (
<>
<Alerts />
<Button onClick={() => add("Hello", AlertVariant.default)}>Add</Button>
</>
<AlertProvider>
<Button onClick={() => addAlert("Hello", AlertVariant.default)}>
Add
</Button>
</AlertProvider>
);
};

View file

@ -0,0 +1,28 @@
import React from "react";
import { Meta } from "@storybook/react";
import {
DownloadDialog,
useDownloadDialog,
} from "../components/download-dialog/DownloadDialog";
export default {
title: "Download Dialog",
component: DownloadDialog,
} as Meta;
const Test = () => {
const [toggle, Dialog] = useDownloadDialog({
id: "58577281-7af7-410c-a085-61ff3040be6d",
});
return (
<>
<button id="show" onClick={toggle}>
Show
</button>
<Dialog />
</>
);
};
export const Show = () => <Test />;

View file

@ -4,6 +4,10 @@ import {
PageHeader,
PageHeaderTools,
PageHeaderToolsItem,
PageSection,
FormGroup,
Form,
TextInput,
} from "@patternfly/react-core";
import { Meta } from "@storybook/react";
@ -13,8 +17,6 @@ import {
HelpContext,
HelpHeader,
} from "../components/help-enabler/HelpHeader";
import { I18nextProvider } from "react-i18next";
import i18n from "../i18n";
export default {
title: "Help System Example",
@ -27,10 +29,30 @@ export const HelpSystem = () => (
</Help>
);
export const HelpItemz = () => (
<I18nextProvider i18n={i18n}>
<HelpItem item="storybook" />
</I18nextProvider>
export const HelpItems = () => (
<HelpItem
helpText="This explains the related field"
forLabel="Field label"
forID="storybook-example-id"
/>
);
export const FormFieldHelp = () => (
<Form isHorizontal>
<FormGroup
label="Label"
labelIcon={
<HelpItem
helpText="This explains the related field"
forLabel="Field label"
forID="storybook-form-help"
/>
}
fieldId="storybook-form-help"
>
<TextInput isRequired type="text" id="storybook-form-help"></TextInput>
</FormGroup>
</Form>
);
const HelpSystemTest = () => {
@ -50,7 +72,10 @@ const HelpSystemTest = () => {
/>
}
>
Help system is {enabled ? "enabled" : "not on, guess you don't need help"}
<PageSection>Help system is {enabled ? "enabled" : "not on"}</PageSection>
<PageSection variant="light">
<FormFieldHelp />
</PageSection>
</Page>
);
};

View file

@ -22,6 +22,6 @@ export const View = Template.bind({});
View.args = {
message: "No things",
instructions: "You haven't created any things for this list.",
primaryActionText: "Add a thing",
onPrimaryAction: handleClick,
primaryActionText: "Add it now!",
secondaryActions: [{ text: "Add a thing", onClick: handleClick }],
};

View file

@ -0,0 +1,18 @@
import React from "react";
import { Meta } from "@storybook/react";
import serverInfo from "../context/server-info/__tests__/mock.json";
import clientScopeMock from "../client-scopes/__tests__/mock-client-scope.json";
import { ServerInfoContext } from "../context/server-info/ServerInfoProvider";
import { MapperList } from "../client-scopes/details/MapperList";
export default {
title: "Mapper List",
component: MapperList,
} as Meta;
export const MapperListExample = () => (
<ServerInfoContext.Provider value={serverInfo}>
<MapperList clientScope={clientScopeMock} />
</ServerInfoContext.Provider>
);

View file

@ -22,6 +22,10 @@ Extended.args = {
titleKey: "This is the title",
badge: "badge",
subKey: "This is the description.",
subKeyLinkProps: {
title: "More information",
href: "http://google.com",
},
selectItems: [
<SelectOption key="first" value="first-item">
First item

View file

@ -0,0 +1,5 @@
{
"storybook": {
"helpPlaceholder": "Sometimes you need some help and it's nice when the app does that"
}
}

View file

@ -1,7 +1,7 @@
import FileSaver from "file-saver";
import { ClientRepresentation } from "./clients/models/client-model";
import { ProviderRepresentation } from "./clients/models/server-info";
import { ProviderRepresentation } from "./context/server-info/server-info";
export const sortProvider = (
a: [string, ProviderRepresentation],