diff --git a/src/clients/ClientDetails.tsx b/src/clients/ClientDetails.tsx index 07f313d735..9de2dd534d 100644 --- a/src/clients/ClientDetails.tsx +++ b/src/clients/ClientDetails.tsx @@ -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(); 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 = () => { <> { )} + {t("clientScopes")}} + > + setActiveTab2(key as number)} + > + {client && ( + {t("setup")}} + > + + + )} + {t("evaluate")}} + > + + + + diff --git a/src/clients/messages.json b/src/clients/messages.json index 17c479fd0c..4f89fac7fb 100644 --- a/src/clients/messages.json +++ b/src/clients/messages.json @@ -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", diff --git a/src/clients/scopes/AddScopeDialog.tsx b/src/clients/scopes/AddScopeDialog.tsx new file mode 100644 index 0000000000..16bde24dba --- /dev/null +++ b/src/clients/scopes/AddScopeDialog.tsx @@ -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 ( + setAddToggle(!addToggle)} + isPrimary + toggleIndicator={CaretUpIcon} + id="add-scope-toggle" + > + {t("common:add")} + + } + dropdownItems={clientScopeTypesDropdown(t)} + />, + , + ]} + > + {}} + rows={data} + aria-label={t("chooseAMapperType")} + > + + +
+
+ ); +}; diff --git a/src/clients/scopes/ClientScopeTypes.tsx b/src/clients/scopes/ClientScopeTypes.tsx new file mode 100644 index 0000000000..9585b35226 --- /dev/null +++ b/src/clients/scopes/ClientScopeTypes.tsx @@ -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) => ( + + {t(`clientScope.${type}`)} + + )); + +export const clientScopeTypesDropdown = (t: TFunction) => + clientScopeTypes.map((type) => ( + {t(`clientScope.${type}`)} + )); diff --git a/src/clients/scopes/ClientScopes.tsx b/src/clients/scopes/ClientScopes.tsx new file mode 100644 index 0000000000..725fe969e4 --- /dev/null +++ b/src/clients/scopes/ClientScopes.tsx @@ -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 ( + + ); +}; + +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("client"); + const [addToggle, setAddToggle] = useState(false); + const [addDialogOpen, setAddDialogOpen] = useState(false); + + const [rows, setRows] = useState(); + const [rest, setRest] = useState(); + + 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 ( + + ); + }; + + const filterData = () => {}; + + return ( + <> + {!rows && ( +
+ +
+ )} + + {rows && rows.length > 0 && ( + <> + {rest && ( + setAddDialogOpen(!addDialogOpen)} + /> + )} + + setSearchToggle(!searchToggle)} + > + {t(`clientScopeSearch.${searchType}`)} + + } + aria-label="Select Input" + isOpen={searchToggle} + dropdownItems={[ + { + setSearchType("client"); + setSearchToggle(false); + }} + > + {t("clientScopeSearch.client")} + , + { + setSearchType("assigned"); + setSearchToggle(false); + }} + > + {t("clientScopeSearch.assigned")} + , + ]} + /> + } + inputGroupName="clientsScopeToolbarTextInput" + inputGroupPlaceholder={t("searchByName")} + inputGroupOnChange={filterData} + toolbarItem={ + + + + + + + + + } + > + {}} + 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")} + > + + +
+
+ + )} + {rows && rows.length === 0 && ( + {}} + /> + )} + + ); +}; diff --git a/src/clients/scopes/EvaluateScopes.tsx b/src/clients/scopes/EvaluateScopes.tsx new file mode 100644 index 0000000000..7c7d084542 --- /dev/null +++ b/src/clients/scopes/EvaluateScopes.tsx @@ -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 ( +
+ + } + > + + + + + + + {isOpen} + + + + +
+ ); +}; diff --git a/src/clients/scopes/evaluate.css b/src/clients/scopes/evaluate.css new file mode 100644 index 0000000000..f8354e447c --- /dev/null +++ b/src/clients/scopes/evaluate.css @@ -0,0 +1,3 @@ +.keycloak__scopes_evaluate__clipboard-copy input { + display: none; +} \ No newline at end of file diff --git a/src/common-messages.json b/src/common-messages.json index 33ba4f7e49..cb8c9a9cc8 100644 --- a/src/common-messages.json +++ b/src/common-messages.json @@ -12,6 +12,7 @@ "cancel": "Cancel", "continue": "Continue", "delete": "Delete", + "remove": "Remove", "search": "Search", "next": "Next", "back": "Back", diff --git a/src/components/table-toolbar/TableToolbar.tsx b/src/components/table-toolbar/TableToolbar.tsx index 7b2f7be10a..f98f05874a 100644 --- a/src/components/table-toolbar/TableToolbar.tsx +++ b/src/components/table-toolbar/TableToolbar.tsx @@ -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 + event: FormEvent ) => void; inputGroupOnClick?: MouseEventHandler; }; @@ -28,6 +34,7 @@ export const TableToolbar = ({ toolbarItem, toolbarItemFooter, children, + searchTypeComponent, inputGroupName, inputGroupPlaceholder, inputGroupOnChange, @@ -38,10 +45,11 @@ export const TableToolbar = ({ <> - + {inputGroupName && ( + {searchTypeComponent} )} - + {toolbarItem}