Initial version of the client scope tab for clients (#227)

* initial version of  client scope tab for clients

* initial version evaluate tab

* changed to use new adminCliend
and merged default and optional

* added add dialog

* removed "always" and "required" scopes

* better seach types dropdown layout

* added switch implementation
This commit is contained in:
Erik Jan de Wit 2020-11-24 21:11:13 +01:00 committed by GitHub
parent ad09c883e3
commit 29a1d82c7f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 543 additions and 8 deletions

View file

@ -29,6 +29,8 @@ import {
convertToMultiline, convertToMultiline,
toValue, toValue,
} from "../components/multi-line-input/MultiLineInput"; } from "../components/multi-line-input/MultiLineInput";
import { ClientScopes } from "./scopes/ClientScopes";
import { EvaluateScopes } from "./scopes/EvaluateScopes";
export const ClientDetails = () => { export const ClientDetails = () => {
const { t } = useTranslation("clients"); const { t } = useTranslation("clients");
@ -44,7 +46,8 @@ export const ClientDetails = () => {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const [activeTab, setActiveTab] = useState(0); const [activeTab, setActiveTab] = useState(0);
const [name, setName] = useState(""); const [activeTab2, setActiveTab2] = useState(30);
const [client, setClient] = useState<ClientRepresentation>();
const [toggleDeleteDialog, DeleteConfirm] = useConfirmDialog({ const [toggleDeleteDialog, DeleteConfirm] = useConfirmDialog({
titleKey: "clients:clientDeleteConfirmTitle", titleKey: "clients:clientDeleteConfirmTitle",
@ -83,7 +86,7 @@ export const ClientDetails = () => {
(async () => { (async () => {
const fetchedClient = await adminClient.clients.findOne({ id }); const fetchedClient = await adminClient.clients.findOne({ id });
if (fetchedClient) { if (fetchedClient) {
setName(fetchedClient.clientId!); setClient(fetchedClient);
setupForm(fetchedClient); setupForm(fetchedClient);
} }
})(); })();
@ -133,7 +136,7 @@ export const ClientDetails = () => {
<> <>
<DisableConfirm /> <DisableConfirm />
<ViewHeader <ViewHeader
titleKey={name} titleKey={client ? client.clientId! : ""}
subKey="clients:clientsExplain" subKey="clients:clientsExplain"
dropdownItems={[ dropdownItems={[
<DropdownItem <DropdownItem
@ -189,6 +192,31 @@ export const ClientDetails = () => {
<Credentials clientId={id} form={form} save={save} /> <Credentials clientId={id} form={form} save={save} />
</Tab> </Tab>
)} )}
<Tab
eventKey={2}
title={<TabTitleText>{t("clientScopes")}</TabTitleText>}
>
<Tabs
activeKey={activeTab2}
isSecondary
onSelect={(_, key) => setActiveTab2(key as number)}
>
{client && (
<Tab
eventKey={30}
title={<TabTitleText>{t("setup")}</TabTitleText>}
>
<ClientScopes clientId={id} protocol={client!.protocol!} />
</Tab>
)}
<Tab
eventKey={31}
title={<TabTitleText>{t("evaluate")}</TabTitleText>}
>
<EvaluateScopes />
</Tab>
</Tabs>
</Tab>
</Tabs> </Tabs>
</PageSection> </PageSection>
</> </>

View file

@ -13,6 +13,24 @@
"downloadAdaptorTitle": "Download adaptor configs", "downloadAdaptorTitle": "Download adaptor configs",
"settings": "Settings", "settings": "Settings",
"credentials": "Credentials", "credentials": "Credentials",
"clientScopes": "Client scopes",
"addClientScope": "Add client scope",
"addClientScopesTo": "Add client scopes to {{clientId}}",
"searchByName": "Search by name",
"setup": "Setup",
"evaluate": "Evaluate",
"changeTypeTo": "Change type to",
"clientScope": {
"default" : "Default",
"optional" : "Optional"
},
"clientScopeSearch": {
"client": "Client scope",
"assigned": "Assigned type"
},
"emptyClientScopes": "This client doesn't have any added client scopes",
"emptyClientScopesInstructions": "There are currently no client scopes linked to this client. You can add existing client scopes to this client to share protocol mappers and roles.",
"emptyClientScopesPrimaryAction": "Add client scopes",
"details": "Details", "details": "Details",
"clientList": "Clients", "clientList": "Clients",
"clientSettings": "Client details", "clientSettings": "Client details",

View file

@ -0,0 +1,87 @@
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import {
Button,
ButtonVariant,
Dropdown,
DropdownToggle,
Modal,
ModalVariant,
DropdownDirection,
} from "@patternfly/react-core";
import { CaretUpIcon } from "@patternfly/react-icons";
import {
Table,
TableBody,
TableHeader,
TableVariant,
} from "@patternfly/react-table";
import ClientScopeRepresentation from "keycloak-admin/lib/defs/clientScopeRepresentation";
import { clientScopeTypesDropdown } from "./ClientScopeTypes";
export type AddScopeDialogProps = {
clientScopes: ClientScopeRepresentation[];
open: boolean;
toggleDialog: () => void;
};
export const AddScopeDialog = ({
clientScopes,
open,
toggleDialog,
}: AddScopeDialogProps) => {
const { t } = useTranslation("clients");
const [addToggle, setAddToggle] = useState(false);
const data = clientScopes.map((scope) => {
return { cells: [scope.name, scope.description] };
});
return (
<Modal
variant={ModalVariant.medium}
title={t("addClientScopesTo", { clientId: "test" })}
isOpen={open}
onClose={toggleDialog}
actions={[
<Dropdown
id="add-dropdown"
key="add-dropdown"
direction={DropdownDirection.up}
isOpen={addToggle}
toggle={
<DropdownToggle
onToggle={() => setAddToggle(!addToggle)}
isPrimary
toggleIndicator={CaretUpIcon}
id="add-scope-toggle"
>
{t("common:add")}
</DropdownToggle>
}
dropdownItems={clientScopeTypesDropdown(t)}
/>,
<Button
id="modal-cancel"
key="cancel"
variant={ButtonVariant.secondary}
onClick={toggleDialog}
>
{t("common:cancel")}
</Button>,
]}
>
<Table
variant={TableVariant.compact}
cells={[t("name"), t("description")]}
onSelect={(_, isSelected, rowIndex) => {}}
rows={data}
aria-label={t("chooseAMapperType")}
>
<TableHeader />
<TableBody />
</Table>
</Modal>
);
};

View file

@ -0,0 +1,22 @@
import React from "react";
import { TFunction } from "i18next";
import { DropdownItem, SelectOption } from "@patternfly/react-core";
export enum ClientScope {
default = "default",
optional = "optional",
}
export type ClientScopeType = ClientScope.default | ClientScope.optional;
const clientScopeTypes = Object.keys(ClientScope);
export const clientScopeTypesSelectOptions = (t: TFunction) =>
clientScopeTypes.map((type) => (
<SelectOption key={type} value={type}>
{t(`clientScope.${type}`)}
</SelectOption>
));
export const clientScopeTypesDropdown = (t: TFunction) =>
clientScopeTypes.map((type) => (
<DropdownItem key={type}>{t(`clientScope.${type}`)}</DropdownItem>
));

View file

@ -0,0 +1,304 @@
import React, { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { TFunction } from "i18next";
import {
IFormatter,
IFormatterValueType,
Table,
TableBody,
TableHeader,
TableVariant,
} from "@patternfly/react-table";
import {
Button,
Dropdown,
DropdownItem,
DropdownToggle,
Select,
Spinner,
Split,
SplitItem,
} from "@patternfly/react-core";
import { FilterIcon } from "@patternfly/react-icons";
import ClientScopeRepresentation from "keycloak-admin/lib/defs/clientScopeRepresentation";
import KeycloakAdminClient from "keycloak-admin";
import { useAdminClient } from "../../context/auth/AdminClient";
import { TableToolbar } from "../../components/table-toolbar/TableToolbar";
import { ListEmptyState } from "../../components/list-empty-state/ListEmptyState";
import { AddScopeDialog } from "./AddScopeDialog";
import {
clientScopeTypesSelectOptions,
ClientScopeType,
ClientScope,
} from "./ClientScopeTypes";
export type ClientScopesProps = {
clientId: string;
protocol: string;
};
const firstUpperCase = (name: string) =>
name.charAt(0).toUpperCase() + name.slice(1);
const changeScope = async (
adminClient: KeycloakAdminClient,
clientId: string,
clientScope: ClientScopeRepresentation,
type: ClientScopeType,
changeTo: ClientScopeType
) => {
const typeToName = firstUpperCase(type);
const changeToName = firstUpperCase(changeTo);
const indexedAdminClient = (adminClient.clients as unknown) as {
[index: string]: Function;
};
await indexedAdminClient[`del${typeToName}ClientScope`]({
id: clientId,
clientScopeId: clientScope.id!,
});
await indexedAdminClient[`add${changeToName}ClientScope`]({
id: clientId,
clientScopeId: clientScope.id!,
});
};
type CellDropdownProps = {
clientId: string;
clientScope: ClientScopeRepresentation;
type: ClientScopeType;
};
const CellDropdown = ({ clientId, clientScope, type }: CellDropdownProps) => {
const adminClient = useAdminClient();
const { t } = useTranslation("clients");
const [open, setOpen] = useState(false);
return (
<Select
key={clientScope.id}
onToggle={() => setOpen(!open)}
isOpen={open}
selections={[type]}
onSelect={(_, value) => {
changeScope(
adminClient,
clientId,
clientScope,
type,
value as ClientScopeType
);
setOpen(false);
}}
>
{clientScopeTypesSelectOptions(t)}
</Select>
);
};
type SearchType = "client" | "assigned";
type TableRow = {
clientScope: ClientScopeRepresentation;
type: ClientScopeType;
cells: (string | undefined)[];
};
export const ClientScopes = ({ clientId, protocol }: ClientScopesProps) => {
const { t } = useTranslation("clients");
const adminClient = useAdminClient();
const [searchToggle, setSearchToggle] = useState(false);
const [searchType, setSearchType] = useState<SearchType>("client");
const [addToggle, setAddToggle] = useState(false);
const [addDialogOpen, setAddDialogOpen] = useState(false);
const [rows, setRows] = useState<TableRow[]>();
const [rest, setRest] = useState<ClientScopeRepresentation[]>();
const loader = async () => {
const defaultClientScopes = await adminClient.clients.listDefaultClientScopes(
{ id: clientId }
);
const optionalClientScopes = await adminClient.clients.listOptionalClientScopes(
{ id: clientId }
);
const clientScopes = await adminClient.clientScopes.find();
const find = (id: string) =>
clientScopes.find((clientScope) => id === clientScope.id)!;
const optional = optionalClientScopes.map((c) => {
const scope = find(c.id!);
return {
clientScope: c,
type: ClientScope.optional,
cells: [c.name, c.id, scope.description],
};
});
const defaultScopes = defaultClientScopes.map((c) => {
const scope = find(c.id!);
return {
clientScope: c,
type: ClientScope.default,
cells: [c.name, c.id, scope.description],
};
});
setRows([...optional, ...defaultScopes]);
};
useEffect(() => {
loader();
}, []);
useEffect(() => {
if (rows) {
loadRest(rows);
}
}, [rows]);
const loadRest = async (rows: { cells: (string | undefined)[] }[]) => {
const clientScopes = await adminClient.clientScopes.find();
const names = rows.map((row) => row.cells[0]);
setRest(
clientScopes
.filter((scope) => !names.includes(scope.name))
.filter((scope) => scope.protocol === protocol)
);
};
const dropdown = (): IFormatter => (data?: IFormatterValueType) => {
if (!data) {
return <></>;
}
const row = rows?.find((row) => row.clientScope.id === data.toString())!;
return (
<CellDropdown
clientId={clientId}
clientScope={row.clientScope}
type={row.type}
/>
);
};
const filterData = () => {};
return (
<>
{!rows && (
<div className="pf-u-text-align-center">
<Spinner />
</div>
)}
{rows && rows.length > 0 && (
<>
{rest && (
<AddScopeDialog
clientScopes={rest}
open={addDialogOpen}
toggleDialog={() => setAddDialogOpen(!addDialogOpen)}
/>
)}
<TableToolbar
searchTypeComponent={
<Dropdown
toggle={
<DropdownToggle
id="toggle-id"
onToggle={() => setSearchToggle(!searchToggle)}
>
<FilterIcon /> {t(`clientScopeSearch.${searchType}`)}
</DropdownToggle>
}
aria-label="Select Input"
isOpen={searchToggle}
dropdownItems={[
<DropdownItem
key="client"
onClick={() => {
setSearchType("client");
setSearchToggle(false);
}}
>
{t("clientScopeSearch.client")}
</DropdownItem>,
<DropdownItem
key="assigned"
onClick={() => {
setSearchType("assigned");
setSearchToggle(false);
}}
>
{t("clientScopeSearch.assigned")}
</DropdownItem>,
]}
/>
}
inputGroupName="clientsScopeToolbarTextInput"
inputGroupPlaceholder={t("searchByName")}
inputGroupOnChange={filterData}
toolbarItem={
<Split hasGutter>
<SplitItem>
<Button onClick={() => setAddDialogOpen(true)}>
{t("addClientScope")}
</Button>
</SplitItem>
<SplitItem>
<Select
id="add-dropdown"
key="add-dropdown"
isOpen={addToggle}
selections={[]}
placeholderText={t("changeTypeTo")}
onToggle={() => setAddToggle(!addToggle)}
onSelect={(_, value) => {
console.log(value);
setAddToggle(false);
}}
>
{clientScopeTypesSelectOptions(t)}
</Select>
</SplitItem>
</Split>
}
>
<Table
onSelect={() => {}}
variant={TableVariant.compact}
cells={[
t("name"),
{ title: t("description"), cellFormatters: [dropdown()] },
t("protocol"),
]}
rows={rows}
actions={[
{
title: t("common:remove"),
onClick: () => {},
},
]}
aria-label={t("clientScopeList")}
>
<TableHeader />
<TableBody />
</Table>
</TableToolbar>
</>
)}
{rows && rows.length === 0 && (
<ListEmptyState
message={t("clients:emptyClientScopes")}
instructions={t("clients:emptyClientScopesInstructions")}
primaryActionText={t("clients:emptyClientScopesPrimaryAction")}
onPrimaryAction={() => {}}
/>
)}
</>
);
};

View file

@ -0,0 +1,64 @@
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import {
ClipboardCopy,
Form,
FormGroup,
Select,
SelectOption,
SelectVariant,
Split,
SplitItem,
} from "@patternfly/react-core";
import { HelpItem } from "../../components/help-enabler/HelpItem";
import "./evaluate.css";
export const EvaluateScopes = () => {
const { t } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
// const [selected]
return (
<Form isHorizontal>
<FormGroup
label={t("rootUrl")}
fieldId="kc-root-url"
labelIcon={
<HelpItem
helpText="client-scopes-help:protocolMapper"
forLabel={t("protocolMapper")}
forID="protocolMapper"
/>
}
>
<Split hasGutter>
<SplitItem isFilled>
<Select
variant={SelectVariant.typeaheadMulti}
typeAheadAriaLabel="Select a state"
onToggle={() => setIsOpen(!isOpen)}
isOpen={isOpen}
aria-labelledby="test"
placeholderText="Select a state"
>
{/* {this.state.options.map((option, index) => (
<SelectOption
isDisabled={option.disabled}
key={index}
value={option.value}
{...(option.description && { description: option.description })}
/>
))} */}
</Select>
</SplitItem>
<SplitItem>
<ClipboardCopy className="keycloak__scopes_evaluate__clipboard-copy">
{isOpen}
</ClipboardCopy>
</SplitItem>
</Split>
</FormGroup>
</Form>
);
};

View file

@ -0,0 +1,3 @@
.keycloak__scopes_evaluate__clipboard-copy input {
display: none;
}

View file

@ -12,6 +12,7 @@
"cancel": "Cancel", "cancel": "Cancel",
"continue": "Continue", "continue": "Continue",
"delete": "Delete", "delete": "Delete",
"remove": "Remove",
"search": "Search", "search": "Search",
"next": "Next", "next": "Next",
"back": "Back", "back": "Back",

View file

@ -1,4 +1,9 @@
import React, { MouseEventHandler, ReactNode } from "react"; import React, {
FormEvent,
Fragment,
MouseEventHandler,
ReactNode,
} from "react";
import { import {
Toolbar, Toolbar,
ToolbarContent, ToolbarContent,
@ -14,12 +19,13 @@ import { useTranslation } from "react-i18next";
type TableToolbarProps = { type TableToolbarProps = {
toolbarItem?: ReactNode; toolbarItem?: ReactNode;
toolbarItemFooter?: ReactNode; toolbarItemFooter?: ReactNode;
children: React.ReactNode; children: ReactNode;
searchTypeComponent?: ReactNode;
inputGroupName?: string; inputGroupName?: string;
inputGroupPlaceholder?: string; inputGroupPlaceholder?: string;
inputGroupOnChange?: ( inputGroupOnChange?: (
newInput: string, newInput: string,
event: React.FormEvent<HTMLInputElement> event: FormEvent<HTMLInputElement>
) => void; ) => void;
inputGroupOnClick?: MouseEventHandler; inputGroupOnClick?: MouseEventHandler;
}; };
@ -28,6 +34,7 @@ export const TableToolbar = ({
toolbarItem, toolbarItem,
toolbarItemFooter, toolbarItemFooter,
children, children,
searchTypeComponent,
inputGroupName, inputGroupName,
inputGroupPlaceholder, inputGroupPlaceholder,
inputGroupOnChange, inputGroupOnChange,
@ -38,10 +45,11 @@ export const TableToolbar = ({
<> <>
<Toolbar> <Toolbar>
<ToolbarContent> <ToolbarContent>
<React.Fragment> <Fragment>
{inputGroupName && ( {inputGroupName && (
<ToolbarItem> <ToolbarItem>
<InputGroup> <InputGroup>
{searchTypeComponent}
<TextInput <TextInput
name={inputGroupName} name={inputGroupName}
id={inputGroupName} id={inputGroupName}
@ -60,7 +68,7 @@ export const TableToolbar = ({
</InputGroup> </InputGroup>
</ToolbarItem> </ToolbarItem>
)} )}
</React.Fragment> </Fragment>
{toolbarItem} {toolbarItem}
</ToolbarContent> </ToolbarContent>
</Toolbar> </Toolbar>