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,
toValue,
} from "../components/multi-line-input/MultiLineInput";
import { ClientScopes } from "./scopes/ClientScopes";
import { EvaluateScopes } from "./scopes/EvaluateScopes";
export const ClientDetails = () => {
const { t } = useTranslation("clients");
@ -44,7 +46,8 @@ export const ClientDetails = () => {
const { id } = useParams<{ id: string }>();
const [activeTab, setActiveTab] = useState(0);
const [name, setName] = useState("");
const [activeTab2, setActiveTab2] = useState(30);
const [client, setClient] = useState<ClientRepresentation>();
const [toggleDeleteDialog, DeleteConfirm] = useConfirmDialog({
titleKey: "clients:clientDeleteConfirmTitle",
@ -83,7 +86,7 @@ export const ClientDetails = () => {
(async () => {
const fetchedClient = await adminClient.clients.findOne({ id });
if (fetchedClient) {
setName(fetchedClient.clientId!);
setClient(fetchedClient);
setupForm(fetchedClient);
}
})();
@ -133,7 +136,7 @@ export const ClientDetails = () => {
<>
<DisableConfirm />
<ViewHeader
titleKey={name}
titleKey={client ? client.clientId! : ""}
subKey="clients:clientsExplain"
dropdownItems={[
<DropdownItem
@ -189,6 +192,31 @@ export const ClientDetails = () => {
<Credentials clientId={id} form={form} save={save} />
</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>
</PageSection>
</>

View file

@ -13,6 +13,24 @@
"downloadAdaptorTitle": "Download adaptor configs",
"settings": "Settings",
"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",
"clientList": "Clients",
"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",
"continue": "Continue",
"delete": "Delete",
"remove": "Remove",
"search": "Search",
"next": "Next",
"back": "Back",

View file

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