From b86db32ba828e5d14c4bd257e679a0e7e6acb42b Mon Sep 17 00:00:00 2001 From: Erik Jan de Wit Date: Tue, 20 Apr 2021 14:10:00 +0200 Subject: [PATCH] Add scope tab to client scope detail page (#514) * initial version of the scope tab * fixed assign * moved form logic added test * added unassign * fixed merge error * fixed labels --- .../integration/client_scopes_test.spec.ts | 20 + cypress/integration/clients_test.spec.ts | 6 +- .../admin_console/manage/RoleMappingTab.ts | 51 +++ .../manage/clients/ServiceAccountTab.ts | 23 - package.json | 2 +- src/client-scopes/details/ScopeForm.tsx | 255 +++++++++++ src/client-scopes/form/ClientScopeForm.tsx | 405 ++++++------------ src/client-scopes/messages.json | 5 +- src/clients/messages.json | 4 + .../service-account/ServiceAccount.tsx | 110 +---- .../role-mapping/AddRoleMappingModal.tsx} | 94 ++-- .../role-mapping/AddRoleMappingModal.tsx.orig | 307 +++++++++++++ src/components/role-mapping/RoleMapping.tsx | 211 +++++++++ .../role-mapping/role-mapping.css} | 2 +- .../table-toolbar/KeycloakDataTable.tsx | 3 +- yarn.lock | 8 +- 16 files changed, 1071 insertions(+), 435 deletions(-) create mode 100644 cypress/support/pages/admin_console/manage/RoleMappingTab.ts delete mode 100644 cypress/support/pages/admin_console/manage/clients/ServiceAccountTab.ts create mode 100644 src/client-scopes/details/ScopeForm.tsx rename src/{clients/service-account/AddServiceAccountModal.tsx => components/role-mapping/AddRoleMappingModal.tsx} (73%) create mode 100644 src/components/role-mapping/AddRoleMappingModal.tsx.orig create mode 100644 src/components/role-mapping/RoleMapping.tsx rename src/{clients/service-account/service-account.css => components/role-mapping/role-mapping.css} (50%) diff --git a/cypress/integration/client_scopes_test.spec.ts b/cypress/integration/client_scopes_test.spec.ts index d570de096a..64fd396b05 100644 --- a/cypress/integration/client_scopes_test.spec.ts +++ b/cypress/integration/client_scopes_test.spec.ts @@ -4,6 +4,7 @@ import ListingPage from "../support/pages/admin_console/ListingPage"; import SidebarPage from "../support/pages/admin_console/SidebarPage"; import CreateClientScopePage from "../support/pages/admin_console/manage/client_scopes/CreateClientScopePage"; import { keycloakBefore } from "../support/util/keycloak_before"; +import RoleMappingTab from "../support/pages/admin_console/manage/RoleMappingTab"; let itemId = "client_scope_crud"; const loginPage = new LoginPage(); @@ -57,4 +58,23 @@ describe("Client Scopes test", function () { .itemExist(itemId, false); }); }); + + describe("Scope test", () => { + const scopeTab = new RoleMappingTab(); + const scopeName = "address"; + + beforeEach(() => { + keycloakBefore(); + loginPage.logIn(); + sidebarPage.goToClientScopes(); + }); + + it("assignRole", () => { + const role = "offline_access"; + listingPage.searchItem(scopeName, false).goToItemDetails(scopeName); + scopeTab.goToScopeTab().clickAssignRole().selectRow(role).clickAssign(); + masthead.checkNotificationMessage("Role mapping updated"); + scopeTab.checkRoles([role]); + }); + }); }); diff --git a/cypress/integration/clients_test.spec.ts b/cypress/integration/clients_test.spec.ts index 00c37611d3..5c472a1dad 100644 --- a/cypress/integration/clients_test.spec.ts +++ b/cypress/integration/clients_test.spec.ts @@ -8,7 +8,7 @@ import AdvancedTab from "../support/pages/admin_console/manage/clients/AdvancedT import AdminClient from "../support/util/AdminClient"; import InitialAccessTokenTab from "../support/pages/admin_console/manage/clients/InitialAccessTokenTab"; import { keycloakBefore } from "../support/util/keycloak_before"; -import ServiceAccountTab from "../support/pages/admin_console/manage/clients/ServiceAccountTab"; +import RoleMappingTab from "../support/pages/admin_console/manage/RoleMappingTab"; let itemId = "client_crud"; const loginPage = new LoginPage(); @@ -165,7 +165,7 @@ describe("Clients test", function () { }); describe("Service account tab test", () => { - const serviceAccountTab = new ServiceAccountTab(); + const serviceAccountTab = new RoleMappingTab(); const serviceAccountName = "service-account-client"; beforeEach(() => { @@ -194,7 +194,7 @@ describe("Clients test", function () { .searchItem(serviceAccountName) .goToItemDetails(serviceAccountName); serviceAccountTab - .goToTab() + .goToServiceAccountTab() .checkRoles(["manage-account", "offline_access", "uma_authorization"]); }); }); diff --git a/cypress/support/pages/admin_console/manage/RoleMappingTab.ts b/cypress/support/pages/admin_console/manage/RoleMappingTab.ts new file mode 100644 index 0000000000..e9493e5acf --- /dev/null +++ b/cypress/support/pages/admin_console/manage/RoleMappingTab.ts @@ -0,0 +1,51 @@ +const expect = chai.expect; +export default class RoleMappingTab { + private tab = "#pf-tab-serviceAccount-serviceAccount"; + private scopeTab = "scopeTab"; + private assignRole = "assignRole"; + private assign = "assign"; + private assignedRolesTable = "assigned-roles"; + private namesColumn = 'td[data-label="Name"]:visible'; + + goToServiceAccountTab() { + cy.get(this.tab).click(); + return this; + } + + goToScopeTab() { + cy.getId(this.scopeTab).click(); + return this; + } + + clickAssignRole() { + cy.getId(this.assignRole).click(); + return this; + } + + clickAssign() { + cy.getId(this.assign).click(); + return this; + } + + selectRow(name: string) { + cy.get(this.namesColumn) + .contains(name) + .parent() + .within(() => { + cy.get("input").click(); + }); + return this; + } + + checkRoles(roleNames: string[]) { + cy.getId(this.assignedRolesTable) + .get(this.namesColumn) + .should((roles) => { + for (let index = 0; index < roleNames.length; index++) { + const roleName = roleNames[index]; + expect(roles).to.contain(roleName); + } + }); + return this; + } +} diff --git a/cypress/support/pages/admin_console/manage/clients/ServiceAccountTab.ts b/cypress/support/pages/admin_console/manage/clients/ServiceAccountTab.ts deleted file mode 100644 index 331dee43d8..0000000000 --- a/cypress/support/pages/admin_console/manage/clients/ServiceAccountTab.ts +++ /dev/null @@ -1,23 +0,0 @@ -const expect = chai.expect; -export default class ServiceAccountTab { - private tab = "#pf-tab-serviceAccount-serviceAccount"; - private assignedRolesTable = "assigned-roles"; - private namesColumn = 'td[data-label="Name"]:visible'; - - goToTab() { - cy.get(this.tab).click(); - return this; - } - - checkRoles(roleNames: string[]) { - cy.getId(this.assignedRolesTable) - .get(this.namesColumn) - .should((roles) => { - for (let index = 0; index < roleNames.length; index++) { - const roleName = roleNames[index]; - expect(roles).to.contain(roleName); - } - }); - return this; - } -} diff --git a/package.json b/package.json index 99896de686..f378a7fba7 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "@patternfly/react-table": "4.24.1", "file-saver": "^2.0.2", "i18next": "^19.6.2", - "keycloak-admin": "1.14.10", + "keycloak-admin": "1.14.11", "lodash": "^4.17.20", "moment": "^2.29.1", "react": "^16.8.5", diff --git a/src/client-scopes/details/ScopeForm.tsx b/src/client-scopes/details/ScopeForm.tsx new file mode 100644 index 0000000000..e1790c1baf --- /dev/null +++ b/src/client-scopes/details/ScopeForm.tsx @@ -0,0 +1,255 @@ +import React, { useEffect, useState } from "react"; +import { useHistory, useParams } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { Controller, useForm } from "react-hook-form"; +import { + Form, + FormGroup, + ValidatedOptions, + TextInput, + Select, + SelectVariant, + SelectOption, + Switch, + ActionGroup, + Button, +} from "@patternfly/react-core"; + +import ClientScopeRepresentation from "keycloak-admin/lib/defs/clientScopeRepresentation"; +import { HelpItem } from "../../components/help-enabler/HelpItem"; +import { useLoginProviders } from "../../context/server-info/ServerInfoProvider"; +import { convertToFormValues } from "../../util"; + +type ScopeFormProps = { + clientScope: ClientScopeRepresentation; + save: (clientScope: ClientScopeRepresentation) => void; +}; + +export const ScopeForm = ({ clientScope, save }: ScopeFormProps) => { + const { t } = useTranslation("client-scopes"); + const { register, control, handleSubmit, errors, setValue } = useForm(); + const history = useHistory(); + const providers = useLoginProviders(); + const [open, isOpen] = useState(false); + const { id } = useParams<{ id: string }>(); + + useEffect(() => { + Object.entries(clientScope).map((entry) => { + if (entry[0] === "attributes") { + convertToFormValues(entry[1], "attributes", setValue); + } + setValue(entry[0], entry[1]); + }); + }, [clientScope]); + + return ( +
+ + } + fieldId="kc-name" + isRequired + validated={ + errors.name ? ValidatedOptions.error : ValidatedOptions.default + } + helperTextInvalid={t("common:required")} + > + + + + } + fieldId="kc-description" + validated={ + errors.description ? ValidatedOptions.error : ValidatedOptions.default + } + helperTextInvalid={t("common:maxLength", { length: 255 })} + > + + + {!id && ( + + } + fieldId="kc-protocol" + > + ( + + )} + /> + + )} + + } + fieldId="kc-display.on.consent.screen" + > + ( + onChange("" + value)} + /> + )} + /> + + + } + fieldId="kc-consent-screen-text" + > + + + + } + fieldId="includeInTokenScope" + > + ( + onChange("" + value)} + /> + )} + /> + + + } + fieldId="kc-gui-order" + helperTextInvalid={t("shouldBeANumber")} + validated={ + errors.attributes && errors.attributes["gui_order"] + ? ValidatedOptions.error + : ValidatedOptions.default + } + > + + + + + + +
+ ); +}; diff --git a/src/client-scopes/form/ClientScopeForm.tsx b/src/client-scopes/form/ClientScopeForm.tsx index 58979a382d..f09bc65bac 100644 --- a/src/client-scopes/form/ClientScopeForm.tsx +++ b/src/client-scopes/form/ClientScopeForm.tsx @@ -1,52 +1,38 @@ import React, { useEffect, useState } from "react"; -import { useHistory, useParams } from "react-router-dom"; +import { useParams } from "react-router-dom"; import { useErrorHandler } from "react-error-boundary"; +import { useTranslation } from "react-i18next"; import { - ActionGroup, AlertVariant, - Button, - Form, - FormGroup, PageSection, - Select, - SelectOption, - SelectVariant, - Switch, + Spinner, Tab, TabTitleText, - TextInput, - ValidatedOptions, } from "@patternfly/react-core"; -import { useTranslation } from "react-i18next"; -import { Controller, useForm } from "react-hook-form"; -import ClientScopeRepresentation from "keycloak-admin/lib/defs/clientScopeRepresentation"; -import { HelpItem } from "../../components/help-enabler/HelpItem"; +import ClientScopeRepresentation from "keycloak-admin/lib/defs/clientScopeRepresentation"; import { useAdminClient, asyncStateFetch, } from "../../context/auth/AdminClient"; import { KeycloakTabs } from "../../components/keycloak-tabs/KeycloakTabs"; import { useAlerts } from "../../components/alert/Alerts"; -import { useLoginProviders } from "../../context/server-info/ServerInfoProvider"; import { ViewHeader } from "../../components/view-header/ViewHeader"; -import { convertFormValuesToObject, convertToFormValues } from "../../util"; +import { convertFormValuesToObject } from "../../util"; import { MapperList } from "../details/MapperList"; +import { ScopeForm } from "../details/ScopeForm"; +import { RoleMapping, Row } from "../../components/role-mapping/RoleMapping"; +import { RoleMappingPayload } from "keycloak-admin/lib/defs/roleRepresentation"; export const ClientScopeForm = () => { const { t } = useTranslation("client-scopes"); - const { register, control, handleSubmit, errors, setValue } = useForm< - ClientScopeRepresentation - >(); - const history = useHistory(); const [clientScope, setClientScope] = useState(); + const [hide, setHide] = useState(false); const adminClient = useAdminClient(); const handleError = useErrorHandler(); - const providers = useLoginProviders(); const { id } = useParams<{ id: string }>(); - const [open, isOpen] = useState(false); const { addAlert } = useAlerts(); const [key, setKey] = useState(0); @@ -56,23 +42,53 @@ export const ClientScopeForm = () => { return asyncStateFetch( async () => { if (id) { - const data = await adminClient.clientScopes.findOne({ id }); - if (data) { - Object.entries(data).map((entry) => { - if (entry[0] === "attributes") { - convertToFormValues(entry[1], "attributes", setValue); - } - setValue(entry[0], entry[1]); - }); - } - - return data; + return await adminClient.clientScopes.findOne({ id }); } }, - (data) => setClientScope(data), + (clientScope) => { + setClientScope(clientScope); + }, handleError ); - }, [key]); + }, [key, id]); + + const loader = async () => { + const assignedRoles = hide + ? await adminClient.clientScopes.listRealmScopeMappings({ id }) + : await adminClient.clientScopes.listCompositeRealmScopeMappings({ id }); + const clients = await adminClient.clients.find(); + + const clientRoles = ( + await Promise.all( + clients.map(async (client) => { + const clientScope = hide + ? await adminClient.clientScopes.listClientScopeMappings({ + id, + client: client.id!, + }) + : await adminClient.clientScopes.listCompositeClientScopeMappings({ + id, + client: client.id!, + }); + return clientScope.map((scope) => { + return { + client, + role: scope, + }; + }); + }) + ) + ).flat(); + + return [ + ...assignedRoles.map((role) => { + return { + role, + }; + }), + ...clientRoles, + ]; + }; const save = async (clientScopes: ClientScopeRepresentation) => { try { @@ -94,6 +110,50 @@ export const ClientScopeForm = () => { } }; + const assignRoles = async (rows: Row[]) => { + try { + const realmRoles = rows + .filter((row) => row.client === undefined) + .map((row) => row.role as RoleMappingPayload) + .flat(); + await adminClient.clientScopes.addRealmScopeMappings( + { + id, + }, + realmRoles + ); + await Promise.all( + rows + .filter((row) => row.client !== undefined) + .map((row) => + adminClient.clientScopes.addClientScopeMappings( + { + id, + client: row.client!.id!, + }, + [row.role as RoleMappingPayload] + ) + ) + ); + addAlert(t("roleMappingUpdatedSuccess"), AlertVariant.success); + } catch (error) { + addAlert( + t("roleMappingUpdatedError", { + error: error.response?.data?.errorMessage || error, + }), + AlertVariant.danger + ); + } + }; + + if (id && !clientScope) { + return ( +
+ +
+ ); + } + return ( <> { } subKey="client-scopes:clientScopeExplain" badge={clientScope ? clientScope.protocol : undefined} + divider={!id} /> - - - {t("common:settings")}} - > -
+ {!id && ( + + + + )} + {id && clientScope && ( + + {t("common:settings")}} + > + + + + + {t("common:mappers")}} > - - } - fieldId="kc-name" - isRequired - validated={ - errors.name - ? ValidatedOptions.error - : ValidatedOptions.default - } - helperTextInvalid={t("common:required")} - > - - - - } - fieldId="kc-description" - validated={ - errors.description - ? ValidatedOptions.error - : ValidatedOptions.default - } - helperTextInvalid={t("common:maxLength", { length: 255 })} - > - - - {!id && ( - - } - fieldId="kc-protocol" - > - ( - - )} - /> - - )} - - } - fieldId="kc-display.on.consent.screen" - > - ( - onChange("" + value)} - /> - )} - /> - - - } - fieldId="kc-consent-screen-text" - > - - - - } - fieldId="includeInTokenScope" - > - ( - onChange("" + value)} - /> - )} - /> - - - } - fieldId="kc-gui-order" - helperTextInvalid={t("shouldBeANumber")} - validated={ - errors.attributes && errors.attributes["gui_order"] - ? ValidatedOptions.error - : ValidatedOptions.default - } - > - - - - - - - - - {t("common:mappers")}} - > - {clientScope && ( - )} - -
+
+ {t("scope")}} + > + setHide(!hide)} + /> + +
+ )}
); diff --git a/src/client-scopes/messages.json b/src/client-scopes/messages.json index 34d745383f..e0f4332d98 100644 --- a/src/client-scopes/messages.json +++ b/src/client-scopes/messages.json @@ -54,6 +54,9 @@ "predefinedMappingDescription": "Choose one of the predefined mappings from this table", "mappingTable": "Table with predefined mapping", "roleGroup": "Use a realm role from:", - "clientGroup": "Use a client role from:" + "clientGroup": "Use a client role from:", + "scope": "Scope", + "roleMappingUpdatedSuccess": "Role mapping updated", + "roleMappingUpdatedError": "Could not update role mapping {{error}}" } } diff --git a/src/clients/messages.json b/src/clients/messages.json index 699d30912a..457a03d864 100644 --- a/src/clients/messages.json +++ b/src/clients/messages.json @@ -30,6 +30,10 @@ "evaluate": "Evaluate", "changeTypeTo": "Change type to", "assignRole": "Assign role", + "unAssignRole": "Unassign", + "removeMappingTitle": "Remove mapping?", + "removeMappingConfirm": "Are you sure you want to remove this mapping?", + "removeMappingConfirm_plural": "Are you sure you want to remove {{count}} mappings", "clientScopeSearch": { "client": "Client scope", "assigned": "Assigned type" diff --git a/src/clients/service-account/ServiceAccount.tsx b/src/clients/service-account/ServiceAccount.tsx index 85ddc510b5..6458ec4f3d 100644 --- a/src/clients/service-account/ServiceAccount.tsx +++ b/src/clients/service-account/ServiceAccount.tsx @@ -1,66 +1,32 @@ import React, { useContext, useState } from "react"; import { useTranslation } from "react-i18next"; -import { - AlertVariant, - Badge, - Button, - Checkbox, - ToolbarItem, -} from "@patternfly/react-core"; +import { AlertVariant } from "@patternfly/react-core"; import RoleRepresentation, { RoleMappingPayload, } from "keycloak-admin/lib/defs/roleRepresentation"; -import ClientRepresentation from "keycloak-admin/lib/defs/clientRepresentation"; import { useAdminClient } from "../../context/auth/AdminClient"; import { RealmContext } from "../../context/realm-context/RealmContext"; -import { KeycloakDataTable } from "../../components/table-toolbar/KeycloakDataTable"; -import { emptyFormatter } from "../../util"; -import { AddServiceAccountModal } from "./AddServiceAccountModal"; - -import "./service-account.css"; import { useAlerts } from "../../components/alert/Alerts"; +import { + CompositeRole, + RoleMapping, + Row, +} from "../../components/role-mapping/RoleMapping"; type ServiceAccountProps = { clientId: string; }; -export type Row = { - client?: ClientRepresentation; - role: CompositeRole | RoleRepresentation; -}; - -export const ServiceRole = ({ role, client }: Row) => ( - <> - {client && ( - - {client.clientId} - - )} - {role.name} - -); - -type CompositeRole = RoleRepresentation & { - parent: RoleRepresentation; -}; - export const ServiceAccount = ({ clientId }: ServiceAccountProps) => { const { t } = useTranslation("clients"); const adminClient = useAdminClient(); const { realm } = useContext(RealmContext); const { addAlert } = useAlerts(); - const [key, setKey] = useState(0); - const refresh = () => setKey(new Date().getTime()); - const [hide, setHide] = useState(false); const [serviceAccountId, setServiceAccountId] = useState(""); - const [showAssign, setShowAssign] = useState(false); + const [name, setName] = useState(""); const loader = async () => { const serviceAccount = await adminClient.clients.getServiceAccountUser({ @@ -75,6 +41,7 @@ export const ServiceAccount = ({ clientId }: ServiceAccountProps) => { }); const clients = await adminClient.clients.find(); + setName(clients.find((c) => c.id === clientId)?.clientId!); const clientRoles = ( await Promise.all( clients.map(async (client) => { @@ -152,7 +119,6 @@ export const ServiceAccount = ({ clientId }: ServiceAccountProps) => { ) ); addAlert(t("roleMappingUpdatedSuccess"), AlertVariant.success); - refresh(); } catch (error) { addAlert( t("roleMappingUpdatedError", { @@ -163,57 +129,13 @@ export const ServiceAccount = ({ clientId }: ServiceAccountProps) => { } }; return ( - <> - {showAssign && ( - setShowAssign(false)} - /> - )} - {}} - searchPlaceholderKey="clients:searchByName" - ariaLabelKey="clients:clientScopeList" - toolbarItem={ - <> - - - - - - - - } - columns={[ - { - name: "role.name", - displayKey: t("name"), - cellRenderer: ServiceRole, - }, - { - name: "role.parent.name", - displayKey: t("inherentFrom"), - cellFormatters: [emptyFormatter()], - }, - { - name: "role.description", - displayKey: t("description"), - cellFormatters: [emptyFormatter()], - }, - ]} - /> - + setHide(!hide)} + /> ); }; diff --git a/src/clients/service-account/AddServiceAccountModal.tsx b/src/components/role-mapping/AddRoleMappingModal.tsx similarity index 73% rename from src/clients/service-account/AddServiceAccountModal.tsx rename to src/components/role-mapping/AddRoleMappingModal.tsx index ecc7d33b83..f14e9468a4 100644 --- a/src/clients/service-account/AddServiceAccountModal.tsx +++ b/src/components/role-mapping/AddRoleMappingModal.tsx @@ -17,18 +17,22 @@ import { ToolbarItem, } from "@patternfly/react-core"; -import { KeycloakDataTable } from "../../components/table-toolbar/KeycloakDataTable"; +import { KeycloakDataTable } from "../table-toolbar/KeycloakDataTable"; import { asyncStateFetch, useAdminClient, } from "../../context/auth/AdminClient"; import ClientRepresentation from "keycloak-admin/lib/defs/clientRepresentation"; import { FilterIcon } from "@patternfly/react-icons"; -import { Row, ServiceRole } from "./ServiceAccount"; +import { Row, ServiceRole } from "./RoleMapping"; +import RoleRepresentation from "keycloak-admin/lib/defs/roleRepresentation"; -type AddServiceAccountModalProps = { - clientId: string; - serviceAccountId: string; +export type MappingType = "service-account" | "client-scope"; + +type AddRoleMappingModalProps = { + id: string; + type: MappingType; + name: string; onAssign: (rows: Row[]) => void; onClose: () => void; }; @@ -41,18 +45,18 @@ const realmRole = { name: "realmRoles", } as ClientRepresentation; -export const AddServiceAccountModal = ({ - clientId, - serviceAccountId, +export const AddRoleMappingModal = ({ + id, + name, + type, onAssign, onClose, -}: AddServiceAccountModalProps) => { +}: AddRoleMappingModalProps) => { const { t } = useTranslation("clients"); const adminClient = useAdminClient(); const errorHandler = useErrorHandler(); const [clients, setClients] = useState([]); - const [name, setName] = useState(); const [searchToggle, setSearchToggle] = useState(false); const [key, setKey] = useState(0); @@ -66,16 +70,25 @@ export const AddServiceAccountModal = ({ asyncStateFetch( async () => { const clients = await adminClient.clients.find(); - setName(clients.find((client) => client.id === clientId)?.clientId); return ( await Promise.all( clients.map(async (client) => { - const roles = await adminClient.users.listAvailableClientRoleMappings( - { - id: serviceAccountId, - clientUniqueId: client.id!, - } - ); + let roles: RoleRepresentation[] = []; + if (type === "service-account") { + roles = await adminClient.users.listAvailableClientRoleMappings( + { + id: id, + clientUniqueId: client.id!, + } + ); + } else if (type === "client-scope") { + roles = await adminClient.clientScopes.listAvailableClientScopeMappings( + { + id, + client: client.id!, + } + ); + } return { roles, client, @@ -114,11 +127,18 @@ export const AddServiceAccountModal = ({ (client) => client.name !== "realmRoles" ); } - const realmRoles = ( - await adminClient.users.listAvailableRealmRoleMappings({ - id: serviceAccountId, - }) - ).map((role) => { + + let availableRoles: RoleRepresentation[] = []; + if (type === "service-account") { + availableRoles = await adminClient.users.listAvailableRealmRoleMappings({ + id, + }); + } else if (type === "client-scope") { + availableRoles = await adminClient.clientScopes.listAvailableRealmScopeMappings( + { id } + ); + } + const realmRoles = availableRoles.map((role) => { return { role, client: undefined, @@ -132,19 +152,27 @@ export const AddServiceAccountModal = ({ const roles = ( await Promise.all( - allClients.map(async (client) => - ( - await adminClient.users.listAvailableClientRoleMappings({ - id: serviceAccountId, - clientUniqueId: client.id!, - }) - ).map((role) => { + allClients.map(async (client) => { + let clientAvailableRoles: RoleRepresentation[] = []; + if (type === "service-account") { + clientAvailableRoles = await adminClient.users.listAvailableClientRoleMappings( + { + id, + clientUniqueId: client.id!, + } + ); + } else if (type === "client-scope") { + clientAvailableRoles = await adminClient.clientScopes.listAvailableClientScopeMappings( + { id, client: client.id! } + ); + } + return clientAvailableRoles.map((role) => { return { role, client, }; - }) - ) + }); + }) ) ).flat(); @@ -173,9 +201,7 @@ export const AddServiceAccountModal = ({ return ( void; + onClose: () => void; +}; + +type ClientRole = ClientRepresentation & { + numberOfRoles: number; +}; + +const realmRole = { + name: "realmRoles", +} as ClientRepresentation; + +export const AddRoleMappingModal = ({ + id, + name, + type, + onAssign, + onClose, +}: AddRoleMappingModalProps) => { + const { t } = useTranslation("clients"); + const adminClient = useAdminClient(); + const errorHandler = useErrorHandler(); + + const [clients, setClients] = useState([]); + const [name, setName] = useState(); + const [searchToggle, setSearchToggle] = useState(false); + + const [key, setKey] = useState(0); + const refresh = () => setKey(new Date().getTime()); + + const [selectedClients, setSelectedClients] = useState([]); + const [selectedRows, setSelectedRows] = useState([]); + + useEffect( + () => + asyncStateFetch( + async () => { + const clients = await adminClient.clients.find(); + setName(clients.find((client) => client.id === clientId)?.clientId); + return ( + await Promise.all( + clients.map(async (client) => { + let roles: RoleRepresentation[] = []; + if (type === "service-account") { + roles = await adminClient.users.listAvailableClientRoleMappings( + { + id: id, + clientUniqueId: client.id!, + } + ); + } else if (type === "client-scope") { + roles = await adminClient.clientScopes.listAvailableClientScopeMappings( + { + id, + client: client.id!, + } + ); + } + return { + roles, + client, + }; + }) + ) + ) + .flat() + .filter((row) => row.roles.length !== 0) + .map((row) => { + return { ...row.client, numberOfRoles: row.roles.length }; + }); + }, + (clients) => { + setClients(clients); + }, + errorHandler + ), + [] + ); + + useEffect(refresh, [searchToggle]); + + const removeClient = (client: ClientRole) => { + setSelectedClients(selectedClients.filter((item) => item.id !== client.id)); + }; + + const loader = async () => { + const realmRolesSelected = _.findIndex( + selectedClients, + (client) => client.name === "realmRoles" + ); + let selected = selectedClients; + if (realmRolesSelected !== -1) { + selected = selectedClients.filter( + (client) => client.name !== "realmRoles" + ); + } + + let availableRoles: RoleRepresentation[] = []; + if (type === "service-account") { + availableRoles = await adminClient.users.listAvailableRealmRoleMappings({ + id, + }); + } else if (type === "client-scope") { + availableRoles = await adminClient.clientScopes.listAvailableRealmScopeMappings( + { id } + ); + } + const realmRoles = availableRoles.map((role) => { + return { + role, + client: undefined, + }; + }); + + const allClients = + selectedClients.length !== 0 + ? selected + : await adminClient.clients.find(); + + const roles = ( + await Promise.all( + allClients.map(async (client) => { + let clientAvailableRoles: RoleRepresentation[] = []; + if (type === "service-account") { + clientAvailableRoles = await adminClient.users.listAvailableClientRoleMappings( + { + id, + clientUniqueId: client.id!, + } + ); + } else if (type === "client-scope") { + clientAvailableRoles = await adminClient.clientScopes.listAvailableClientScopeMappings( + { id, client: client.id! } + ); + } + return clientAvailableRoles.map((role) => { + return { + role, + client, + }; + }); + }) + ) + ).flat(); + + return [ + ...(realmRolesSelected !== -1 || selected.length === 0 ? realmRoles : []), + ...roles, + ]; + }; + + const createSelectGroup = (clients: ClientRepresentation[]) => [ + + + {t("realmRoles")} + + , + , + + {clients.map((client) => ( + + {client.clientId} + + ))} + , + ]; + + return ( + >>>>>> 0f6f6ab (fixed assign):src/components/role-mapping/AddRoleMappingModal.tsx + isOpen={true} + onClose={onClose} + actions={[ + , + , + ]} + > + + + + {selectedClients.map((client) => ( + { + removeClient(client); + refresh(); + }} + > + {client.clientId || t("realmRoles")} + {client.numberOfRoles} + + ))} + + + + setSelectedRows([...rows])} + searchPlaceholderKey="clients:searchByRoleName" + canSelectAll={false} + loader={loader} + ariaLabelKey="clients:roles" + columns={[ + { + name: "name", + cellRenderer: ServiceRole, + }, + { + name: "role.description", + displayKey: t("description"), + }, + ]} + /> + + ); +}; diff --git a/src/components/role-mapping/RoleMapping.tsx b/src/components/role-mapping/RoleMapping.tsx new file mode 100644 index 0000000000..41879b7ee1 --- /dev/null +++ b/src/components/role-mapping/RoleMapping.tsx @@ -0,0 +1,211 @@ +import React, { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + AlertVariant, + Badge, + Button, + ButtonVariant, + Checkbox, + ToolbarItem, +} from "@patternfly/react-core"; + +import ClientRepresentation from "keycloak-admin/lib/defs/clientRepresentation"; +import RoleRepresentation from "keycloak-admin/lib/defs/roleRepresentation"; +import { AddRoleMappingModal, MappingType } from "./AddRoleMappingModal"; +import { KeycloakDataTable } from "../table-toolbar/KeycloakDataTable"; +import { emptyFormatter } from "../../util"; + +import "./role-mapping.css"; +import { useConfirmDialog } from "../confirm-dialog/ConfirmDialog"; +import { useAdminClient } from "../../context/auth/AdminClient"; +import { useAlerts } from "../alert/Alerts"; +import _ from "lodash"; + +export type CompositeRole = RoleRepresentation & { + parent: RoleRepresentation; +}; + +export type Row = { + client?: ClientRepresentation; + role: CompositeRole | RoleRepresentation; +}; + +export const ServiceRole = ({ role, client }: Row) => ( + <> + {client && ( + + {client.clientId} + + )} + {role.name} + +); + +type RoleMappingProps = { + name: string; + id: string; + type: MappingType; + loader: () => Promise; + save: (rows: Row[]) => Promise; + onHideRolesToggle: () => void; +}; + +export const RoleMapping = ({ + name, + id, + type, + loader, + save, + onHideRolesToggle, +}: RoleMappingProps) => { + const { t } = useTranslation("clients"); + const adminClient = useAdminClient(); + const { addAlert } = useAlerts(); + + const [key, setKey] = useState(0); + const refresh = () => setKey(new Date().getTime()); + + const [hide, setHide] = useState(false); + const [showAssign, setShowAssign] = useState(false); + const [selected, setSelected] = useState([]); + + const assignRoles = async (rows: Row[]) => { + await save(rows); + refresh(); + }; + + const [toggleDeleteDialog, DeleteConfirm] = useConfirmDialog({ + titleKey: "clients:removeMappingTitle", + messageKey: t("removeMappingConfirm", { count: selected.length }), + continueButtonLabel: "common:remove", + continueButtonVariant: ButtonVariant.danger, + onConfirm: async () => { + try { + if (type === "service-account") { + await Promise.all( + selected.map((row) => { + const role = { id: row.role.id!, name: row.role.name! }; + if (row.client) { + return adminClient.users.delClientRoleMappings({ + id, + clientUniqueId: row.client!.id!, + roles: [role], + }); + } else { + return adminClient.users.delRealmRoleMappings({ + id, + roles: [role], + }); + } + }) + ); + } else if (type === "client-scope") { + await Promise.all( + selected.map((row) => { + const role = { id: row.role.id!, name: row.role.name! }; + if (row.client) { + return adminClient.clientScopes.delClientScopeMappings( + { + id, + client: row.client!.id!, + }, + [role] + ); + } else { + return adminClient.clientScopes.delRealmScopeMappings( + { + id, + }, + [role] + ); + } + }) + ); + } + addAlert(t("clientScopeRemoveSuccess"), AlertVariant.success); + refresh(); + } catch (error) { + addAlert(t("clientScopeRemoveError", { error }), AlertVariant.danger); + } + }, + }); + + return ( + <> + {showAssign && ( + setShowAssign(false)} + /> + )} + + setSelected(rows) : undefined} + searchPlaceholderKey="clients:searchByName" + ariaLabelKey="clients:clientScopeList" + toolbarItem={ + <> + + { + setHide(check); + onHideRolesToggle(); + refresh(); + }} + /> + + + + + + + + + } + columns={[ + { + name: "role.name", + displayKey: t("name"), + cellRenderer: ServiceRole, + }, + { + name: "role.parent.name", + displayKey: t("inherentFrom"), + cellFormatters: [emptyFormatter()], + }, + { + name: "role.description", + displayKey: t("description"), + cellFormatters: [emptyFormatter()], + }, + ]} + /> + + ); +}; diff --git a/src/clients/service-account/service-account.css b/src/components/role-mapping/role-mapping.css similarity index 50% rename from src/clients/service-account/service-account.css rename to src/components/role-mapping/role-mapping.css index 8ee94b4785..a3d9a84e91 100644 --- a/src/clients/service-account/service-account.css +++ b/src/components/role-mapping/role-mapping.css @@ -1,5 +1,5 @@ -.keycloak-admin--service-account__client-name { +.keycloak-admin--role-mapping__client-name { margin-right: var(--pf-global--spacer--sm); } \ No newline at end of file diff --git a/src/components/table-toolbar/KeycloakDataTable.tsx b/src/components/table-toolbar/KeycloakDataTable.tsx index fee52c73b2..d47777f57d 100644 --- a/src/components/table-toolbar/KeycloakDataTable.tsx +++ b/src/components/table-toolbar/KeycloakDataTable.tsx @@ -173,10 +173,9 @@ export function KeycloakDataTable({ }, [selected]); useEffect(() => { + setLoading(true); return asyncStateFetch( async () => { - setLoading(true); - let data = unPaginatedData || (await loader(first, max, search)); if (!isPaginated) { diff --git a/yarn.lock b/yarn.lock index ffa23c9bd3..078e28ed97 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13471,10 +13471,10 @@ junk@^3.1.0: resolved "https://registry.yarnpkg.com/junk/-/junk-3.1.0.tgz#31499098d902b7e98c5d9b9c80f43457a88abfa1" integrity sha512-pBxcB3LFc8QVgdggvZWyeys+hnrNWg4OcZIU/1X59k5jQdLBlCsYGRQaz234SqoRLTCgMH00fY0xRJH+F9METQ== -keycloak-admin@1.14.10: - version "1.14.10" - resolved "https://registry.yarnpkg.com/keycloak-admin/-/keycloak-admin-1.14.10.tgz#e44903826896262b3655303db46795b84a5f9b08" - integrity sha512-WhEA+FkcPikN/Oqh7L0puVkPU1cm3bB+15VOoPdESZknQ9poS0Ohz3Rg1flRfmMdqoMgcy+prigUPtHy6gOAUg== +keycloak-admin@1.14.11: + version "1.14.11" + resolved "https://registry.yarnpkg.com/keycloak-admin/-/keycloak-admin-1.14.11.tgz#71415395eeb014f5a8675c951b23596ba33b6f35" + integrity sha512-s0NNLdJ27oAx52pXsvJgm8O/KDb0dbPsnbc+f4uTaz/Gzh6QN6GJPCgAYJEZj/Re+oOm+OVRHTx8bhhlrom5hA== dependencies: axios "^0.21.0" camelize "^1.0.0"