diff --git a/src/client-scopes/ChangeTypeDialog.tsx b/src/client-scopes/ChangeTypeDialog.tsx new file mode 100644 index 0000000000..d146b24954 --- /dev/null +++ b/src/client-scopes/ChangeTypeDialog.tsx @@ -0,0 +1,71 @@ +import React, { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + Button, + ButtonVariant, + Form, + Modal, + Radio, +} from "@patternfly/react-core"; +import { + AllClientScopes, + AllClientScopeType, + allClientScopeTypes, +} from "../components/client-scope/ClientScopeTypes"; + +type ChangeTypeDialogProps = { + selectedClientScopes: number; + onConfirm: (scope: AllClientScopeType) => void; + onClose: () => void; +}; + +export const ChangeTypeDialog = ({ + selectedClientScopes, + onConfirm, + onClose, +}: ChangeTypeDialogProps) => { + const { t } = useTranslation("client-scopes"); + const [value, setValue] = useState(AllClientScopes.none); + return ( + onConfirm(value)} + > + {t("common:continue")} + , + , + ]} + > +
+ {allClientScopeTypes.map((scope) => ( + { + const { value } = event.currentTarget; + setValue(value as AllClientScopeType); + }} + label={t(`common:clientScope.${scope}`)} + id={`radio-${scope}`} + value={scope} + /> + ))} + +
+ ); +}; diff --git a/src/client-scopes/ClientScopesSection.tsx b/src/client-scopes/ClientScopesSection.tsx index 2ec9ad408d..65e1b1f279 100644 --- a/src/client-scopes/ClientScopesSection.tsx +++ b/src/client-scopes/ClientScopesSection.tsx @@ -1,13 +1,80 @@ -import React from "react"; +import React, { useState } from "react"; import { useTranslation } from "react-i18next"; import { Link, useHistory, useRouteMatch } from "react-router-dom"; -import { AlertVariant, Button, PageSection } from "@patternfly/react-core"; +import { + AlertVariant, + Button, + ButtonVariant, + Dropdown, + DropdownItem, + KebabToggle, + PageSection, + ToolbarItem, +} from "@patternfly/react-core"; +import { cellWidth } from "@patternfly/react-table"; import ClientScopeRepresentation from "keycloak-admin/lib/defs/clientScopeRepresentation"; import { useAdminClient } from "../context/auth/AdminClient"; import { ViewHeader } from "../components/view-header/ViewHeader"; import { useAlerts } from "../components/alert/Alerts"; import { KeycloakDataTable } from "../components/table-toolbar/KeycloakDataTable"; +import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog"; +import { emptyFormatter } from "../util"; +import { + CellDropdown, + ClientScope, + AllClientScopes, + AllClientScopeType, +} from "../components/client-scope/ClientScopeTypes"; + +type ClientScopeDefaultOptionalType = ClientScopeRepresentation & { + type: AllClientScopeType; +}; + +import "./client-scope.css"; +import KeycloakAdminClient from "keycloak-admin"; +import { ChangeTypeDialog } from "./ChangeTypeDialog"; + +const castAdminClient = (adminClient: KeycloakAdminClient) => + (adminClient.clientScopes as unknown) as { + [index: string]: Function; + }; + +const changeScope = async ( + adminClient: KeycloakAdminClient, + clientScope: ClientScopeDefaultOptionalType, + changeTo: AllClientScopeType +) => { + await removeScope(adminClient, clientScope); + await addScope(adminClient, clientScope, changeTo); +}; + +const removeScope = async ( + adminClient: KeycloakAdminClient, + clientScope: ClientScopeDefaultOptionalType +) => { + if (clientScope.type !== AllClientScopes.none) + await castAdminClient(adminClient)[ + `delDefault${ + clientScope.type === ClientScope.optional ? "Optional" : "" + }ClientScope` + ]({ + id: clientScope.id!, + }); +}; + +const addScope = async ( + adminClient: KeycloakAdminClient, + clientScope: ClientScopeDefaultOptionalType, + type: AllClientScopeType +) => { + if (type !== AllClientScopes.none) + await castAdminClient(adminClient)[ + `addDefault${type === ClientScope.optional ? "Optional" : ""}ClientScope` + ]({ + id: clientScope.id!, + }); +}; export const ClientScopesSection = () => { const { t } = useTranslation("client-scopes"); @@ -17,7 +84,83 @@ export const ClientScopesSection = () => { const adminClient = useAdminClient(); const { addAlert } = useAlerts(); - const loader = async () => await adminClient.clientScopes.find(); + const [key, setKey] = useState(0); + const refresh = () => setKey(new Date().getTime()); + + const [kebabOpen, setKebabOpen] = useState(false); + const [changeTypeOpen, setChangeTypeOpen] = useState(false); + const [selectedScopes, setSelectedScopes] = useState< + ClientScopeDefaultOptionalType[] + >([]); + + const loader = async () => { + const defaultScopes = await adminClient.clientScopes.listDefaultClientScopes(); + const optionalScopes = await adminClient.clientScopes.listDefaultOptionalClientScopes(); + + const clientScopes = (await adminClient.clientScopes.find()).map( + (scope) => { + return { + ...scope, + type: defaultScopes.find( + (defaultScope) => defaultScope.name === scope.name + ) + ? ClientScope.default + : optionalScopes.find( + (optionalScope) => optionalScope.name === scope.name + ) + ? ClientScope.optional + : AllClientScopes.none, + }; + } + ); + + return clientScopes; + }; + + const [toggleDeleteDialog, DeleteConfirm] = useConfirmDialog({ + titleKey: t("deleteClientScope", { + count: selectedScopes.length, + name: selectedScopes[0]?.name, + }), + messageKey: "client-scopes:deleteConfirm", + continueButtonLabel: "common:delete", + continueButtonVariant: ButtonVariant.danger, + onConfirm: async () => { + try { + for (const scope of selectedScopes) { + await adminClient.clientScopes.del({ id: scope.id! }); + } + addAlert(t("deletedSuccess"), AlertVariant.success); + refresh(); + } catch (error) { + addAlert( + t("deleteError", { + error: error.response?.data?.errorMessage || error, + }), + AlertVariant.danger + ); + } + }, + }); + + const TypeSelector = (scope: ClientScopeDefaultOptionalType) => ( + <> + { + try { + await changeScope(adminClient, scope, value); + addAlert(t("clientScopeSuccess"), AlertVariant.success); + refresh(); + } catch (error) { + addAlert(t("clientScopeError", { error }), AlertVariant.danger); + } + }} + /> + + ); const ClientScopeDetailLink = (clientScope: ClientScopeRepresentation) => ( <> @@ -28,19 +171,79 @@ export const ClientScopesSection = () => { ); return ( <> + + {changeTypeOpen && ( + { + selectedScopes.map(async (scope) => { + try { + await changeScope(adminClient, scope, type); + addAlert(t("clientScopeSuccess"), AlertVariant.success); + refresh(); + } catch (error) { + addAlert(t("clientScopeError", { error }), AlertVariant.danger); + } + }); + setChangeTypeOpen(false); + }} + onClose={() => setChangeTypeOpen(false)} + /> + )} setSelectedScopes([...clientScopes])} + canSelectAll toolbarItem={ - + <> + + + + + setKebabOpen(!kebabOpen)} /> + } + isOpen={kebabOpen} + isPlain + dropdownItems={[ + { + setChangeTypeOpen(true); + setKebabOpen(false); + }} + > + {t("changeType")} + , + + { + toggleDeleteDialog(); + setKebabOpen(false); + }} + > + {t("common:delete")} + , + ]} + /> + + } actions={[ { @@ -49,20 +252,9 @@ export const ClientScopesSection = () => { }, { title: t("common:delete"), - onRowClick: async (clientScope) => { - try { - await adminClient.clientScopes.del({ id: clientScope.id! }); - addAlert(t("deletedSuccess"), AlertVariant.success); - return true; - } catch (error) { - addAlert( - t("deleteError", { - error: error.response?.data?.errorMessage || error, - }), - AlertVariant.danger - ); - return false; - } + onRowClick: (clientScope) => { + setSelectedScopes([clientScope]); + toggleDeleteDialog(); }, }, ]} @@ -71,11 +263,18 @@ export const ClientScopesSection = () => { name: "name", cellRenderer: ClientScopeDetailLink, }, - { name: "description" }, + { name: "description", cellFormatters: [emptyFormatter()] }, + { name: "type", cellRenderer: TypeSelector }, { name: "protocol", displayKey: "client-scopes:protocol", }, + { + name: "attributes['gui.order']", + displayKey: "client-scopes:displayOrder", + cellFormatters: [emptyFormatter()], + transforms: [cellWidth(20)], + }, ]} /> diff --git a/src/client-scopes/client-scope.css b/src/client-scopes/client-scope.css new file mode 100644 index 0000000000..fc8e7f2f44 --- /dev/null +++ b/src/client-scopes/client-scope.css @@ -0,0 +1,4 @@ + +.keycloak__client-scope__none > button .pf-c-select__toggle-text { + color: var(--pf-global--Color--400); +} \ No newline at end of file diff --git a/src/client-scopes/messages.json b/src/client-scopes/messages.json index fe2517dfcb..8691815e0a 100644 --- a/src/client-scopes/messages.json +++ b/src/client-scopes/messages.json @@ -6,6 +6,14 @@ "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", "protocol": "Protocol", + "displayOrder": "Display order", + "deleteClientScope": "Delete client scope {{name}}", + "deleteClientScope_plural": "Delete {{count}} client scopes", + "deleteConfirm": "Are you sure you want to delete this client scope", + "changeType": "Change type", + "changeTypeIntro": "{{count}} selected client scopes will be changed to", + "clientScopeSuccess": "Scope mapping updated", + "clientScopeError": "Could not update scope mapping {{error}}", "deletedSuccess": "The client scope has been deleted", "deleteError": "Could not delete client scope: {{error}}", "includeInTokenScope": "Include in token scope", diff --git a/src/clients/messages.json b/src/clients/messages.json index 33e38ee174..8b229f969d 100644 --- a/src/clients/messages.json +++ b/src/clients/messages.json @@ -30,10 +30,6 @@ "evaluate": "Evaluate", "changeTypeTo": "Change type to", "assignRole": "Assign role", - "clientScope": { - "default": "Default", - "optional": "Optional" - }, "clientScopeSearch": { "client": "Client scope", "assigned": "Assigned type" diff --git a/src/clients/scopes/AddScopeDialog.tsx b/src/clients/scopes/AddScopeDialog.tsx index d6e4c9c7bb..e513986614 100644 --- a/src/clients/scopes/AddScopeDialog.tsx +++ b/src/clients/scopes/AddScopeDialog.tsx @@ -18,7 +18,10 @@ import { } from "@patternfly/react-table"; import ClientScopeRepresentation from "keycloak-admin/lib/defs/clientScopeRepresentation"; -import { ClientScopeType, clientScopeTypesDropdown } from "./ClientScopeTypes"; +import { + ClientScopeType, + clientScopeTypesDropdown, +} from "../../components/client-scope/ClientScopeTypes"; export type AddScopeDialogProps = { clientScopes: ClientScopeRepresentation[]; diff --git a/src/clients/scopes/ClientScopeTypes.tsx b/src/clients/scopes/ClientScopeTypes.tsx deleted file mode 100644 index cdaefaba93..0000000000 --- a/src/clients/scopes/ClientScopeTypes.tsx +++ /dev/null @@ -1,27 +0,0 @@ -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, - onClick: (scope: ClientScopeType) => void -) => - clientScopeTypes.map((type) => ( - onClick(type as ClientScopeType)}> - {t(`clientScope.${type}`)} - - )); diff --git a/src/clients/scopes/ClientScopes.tsx b/src/clients/scopes/ClientScopes.tsx index fc5bf9db2d..8a3e500c83 100644 --- a/src/clients/scopes/ClientScopes.tsx +++ b/src/clients/scopes/ClientScopes.tsx @@ -22,7 +22,8 @@ import { clientScopeTypesSelectOptions, ClientScopeType, ClientScope, -} from "./ClientScopeTypes"; + CellDropdown, +} from "../../components/client-scope/ClientScopeTypes"; import { useAlerts } from "../../components/alert/Alerts"; import { KeycloakDataTable } from "../../components/table-toolbar/KeycloakDataTable"; @@ -78,41 +79,8 @@ const addScope = async ( }); }; -type CellDropdownProps = { - clientScope: ClientScopeRepresentation; - type: ClientScopeType; - onSelect: (value: ClientScopeType) => void; -}; - -const CellDropdown = ({ clientScope, type, onSelect }: CellDropdownProps) => { - const { t } = useTranslation("clients"); - const [open, setOpen] = useState(false); - - return ( - - ); -}; - type SearchType = "client" | "assigned"; -type TableRow = { - selected: boolean; - clientScope: ClientScopeRepresentation; - type: ClientScopeType; - cells: (string | undefined)[]; -}; - export const ClientScopes = ({ clientId, protocol }: ClientScopesProps) => { const { t } = useTranslation("clients"); const adminClient = useAdminClient(); @@ -178,7 +146,13 @@ export const ClientScopes = ({ clientId, protocol }: ClientScopesProps) => { type={scope.type} onSelect={async (value) => { try { - await changeScope(adminClient, clientId, scope, scope.type, value); + await changeScope( + adminClient, + clientId, + scope, + scope.type, + value as ClientScope + ); addAlert(t("clientScopeSuccess"), AlertVariant.success); refresh(); } catch (error) { diff --git a/src/common-messages.json b/src/common-messages.json index 9d855887ae..0db0b31e01 100644 --- a/src/common-messages.json +++ b/src/common-messages.json @@ -55,6 +55,12 @@ "unexpectedError": "An unexpected error occurred: '{{error}}'", "retry": "Retry", + "clientScope": { + "default": "Default", + "optional": "Optional", + "none": "None" + }, + "home": "Home", "manage": "Manage", "clients": "Clients", diff --git a/src/components/client-scope/ClientScopeTypes.tsx b/src/components/client-scope/ClientScopeTypes.tsx new file mode 100644 index 0000000000..6dc2323199 --- /dev/null +++ b/src/components/client-scope/ClientScopeTypes.tsx @@ -0,0 +1,83 @@ +import React, { useState } from "react"; + +import { TFunction } from "i18next"; +import { useTranslation } from "react-i18next"; +import { DropdownItem, Select, SelectOption } from "@patternfly/react-core"; + +import ClientScopeRepresentation from "keycloak-admin/lib/defs/clientScopeRepresentation"; + +export enum ClientScope { + default = "default", + optional = "optional", +} + +export enum AllClientScopes { + none = "none", +} + +export type ClientScopeType = ClientScope; +export type AllClientScopeType = ClientScope | AllClientScopes; + +const clientScopeTypes = Object.keys(ClientScope); +export const allClientScopeTypes = Object.keys({ + ...AllClientScopes, + ...ClientScope, +}); + +export const clientScopeTypesSelectOptions = ( + t: TFunction, + scopeTypes: string[] | undefined = clientScopeTypes +) => + scopeTypes.map((type) => ( + + {t(`common:clientScope.${type}`)} + + )); + +export const clientScopeTypesDropdown = ( + t: TFunction, + onClick: (scope: ClientScopeType) => void +) => + clientScopeTypes.map((type) => ( + onClick(type as ClientScopeType)}> + {t(`common:clientScope.${type}`)} + + )); + +type CellDropdownProps = { + clientScope: ClientScopeRepresentation; + type: ClientScopeType | AllClientScopeType; + all?: boolean; + onSelect: (value: ClientScopeType | AllClientScopeType) => void; +}; + +export const CellDropdown = ({ + clientScope, + type, + onSelect, + all = false, +}: CellDropdownProps) => { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + + return ( + + ); +};