diff --git a/cypress/integration/clients_test.spec.ts b/cypress/integration/clients_test.spec.ts index d90000be4d..00c37611d3 100644 --- a/cypress/integration/clients_test.spec.ts +++ b/cypress/integration/clients_test.spec.ts @@ -8,6 +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"; let itemId = "client_crud"; const loginPage = new LoginPage(); @@ -162,4 +163,39 @@ describe("Clients test", function () { advancedTab.checkAccessTokenSignatureAlgorithm(algorithm); }); }); + + describe("Service account tab test", () => { + const serviceAccountTab = new ServiceAccountTab(); + const serviceAccountName = "service-account-client"; + + beforeEach(() => { + keycloakBefore(); + loginPage.logIn(); + sidebarPage.goToClients(); + }); + + before(async () => { + await new AdminClient().createClient({ + protocol: "openid-connect", + clientId: serviceAccountName, + publicClient: false, + authorizationServicesEnabled: true, + serviceAccountsEnabled: true, + standardFlowEnabled: true, + }); + }); + + after(() => { + new AdminClient().deleteClient(serviceAccountName); + }); + + it("list", () => { + listingPage + .searchItem(serviceAccountName) + .goToItemDetails(serviceAccountName); + serviceAccountTab + .goToTab() + .checkRoles(["manage-account", "offline_access", "uma_authorization"]); + }); + }); }); diff --git a/cypress/support/pages/admin_console/manage/clients/CreateClientPage.ts b/cypress/support/pages/admin_console/manage/clients/CreateClientPage.ts index 556c825c27..589bc88212 100644 --- a/cypress/support/pages/admin_console/manage/clients/CreateClientPage.ts +++ b/cypress/support/pages/admin_console/manage/clients/CreateClientPage.ts @@ -64,6 +64,13 @@ export default class CreateClientPage { return this; } + changeSwitches(switches: string[]) { + for (const uiSwitch of switches) { + cy.getId(uiSwitch).check({ force: true }); + } + return this; + } + checkClientTypeRequiredMessage(exist = true) { cy.get(this.clientTypeError).should((!exist ? "not." : "") + "exist"); diff --git a/cypress/support/pages/admin_console/manage/clients/ServiceAccountTab.ts b/cypress/support/pages/admin_console/manage/clients/ServiceAccountTab.ts new file mode 100644 index 0000000000..331dee43d8 --- /dev/null +++ b/cypress/support/pages/admin_console/manage/clients/ServiceAccountTab.ts @@ -0,0 +1,23 @@ +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/cypress/support/util/AdminClient.ts b/cypress/support/util/AdminClient.ts index e980f85f19..9052f3bd1f 100644 --- a/cypress/support/util/AdminClient.ts +++ b/cypress/support/util/AdminClient.ts @@ -1,5 +1,6 @@ import KeycloakAdminClient from "keycloak-admin"; import UserRepresentation from "keycloak-admin/lib/defs/userRepresentation"; +import ClientRepresentation from "keycloak-admin/lib/defs/clientRepresentation"; export default class AdminClient { private client: KeycloakAdminClient; @@ -24,6 +25,10 @@ export default class AdminClient { await this.client.realms.del({ realm }); } + async createClient(client: ClientRepresentation) { + await this.login(); + await this.client.clients.create(client); + } async deleteClient(clientName: string) { await this.login(); const client = ( diff --git a/src/clients/AdvancedTab.tsx b/src/clients/AdvancedTab.tsx index 89e3adcfe0..81c7176f11 100644 --- a/src/clients/AdvancedTab.tsx +++ b/src/clients/AdvancedTab.tsx @@ -10,6 +10,7 @@ import { ButtonVariant, ExpandableSection, FormGroup, + PageSection, Split, SplitItem, Text, @@ -176,257 +177,261 @@ export const AdvancedTab = ({ } return ( - - <> - - - In order to successfully push setup url on - {t("settings")} - tab - - - - - } - > - - - - - - - - - - <> - - - } - > - - - ( - - )} - /> - - - - - - - + + <> - - { - nodes[node] = moment.now() / 1000; - refresh(); - }} - onClose={() => setAddNodeOpen(false)} - /> - setExpanded(!expanded)} - isExpanded={expanded} - > - - Promise.resolve( - Object.entries(nodes || {}).map((entry) => { - return { host: entry[0], registration: entry[1] }; - }) - ) + + + In order to successfully push setup url on + + {t("settings")} + + tab + + + + } - toolbarItem={ - <> - - - - - - - - } - actions={[ - { - title: t("common:delete"), - onRowClick: (node) => { - setSelectedNode(node.host); - toggleDeleteNodeConfirm(); - }, - }, - ]} - columns={[ - { - name: "host", - displayKey: "clients:nodeHost", - }, - { - name: "registration", - displayKey: "clients:lastRegistration", - cellFormatters: [ - (value) => - value - ? moment(parseInt(value.toString()) * 1000).format( - "LLL" - ) - : "", - ], - }, - ]} - /> - + > + + + + + + + + + + <> + + + } + > + + + ( + + )} + /> + + + + + + + + <> + + { + nodes[node] = moment.now() / 1000; + refresh(); + }} + onClose={() => setAddNodeOpen(false)} + /> + setExpanded(!expanded)} + isExpanded={expanded} + > + + Promise.resolve( + Object.entries(nodes || {}).map((entry) => { + return { host: entry[0], registration: entry[1] }; + }) + ) + } + toolbarItem={ + <> + + + + + + + + } + actions={[ + { + title: t("common:delete"), + onRowClick: (node) => { + setSelectedNode(node.host); + toggleDeleteNodeConfirm(); + }, + }, + ]} + columns={[ + { + name: "host", + displayKey: "clients:nodeHost", + }, + { + name: "registration", + displayKey: "clients:lastRegistration", + cellFormatters: [ + (value) => + value + ? moment(parseInt(value.toString()) * 1000).format( + "LLL" + ) + : "", + ], + }, + ]} + /> + + + + <> + {protocol === openIdConnect && ( + <> + + {t("clients-help:fineGrainOpenIdConnectConfiguration")} + + save()} + reset={() => + convertToFormValues(attributes, "attributes", setValue) + } + /> + + )} + {protocol !== openIdConnect && ( + <> + + {t("clients-help:fineGrainSamlEndpointConfig")} + + save()} + reset={() => + convertToFormValues(attributes, "attributes", setValue) + } + /> + + )} - - <> {protocol === openIdConnect && ( <> - {t("clients-help:fineGrainOpenIdConnectConfiguration")} + {t("clients-help:openIdConnectCompatibilityModes")} - save()} reset={() => - convertToFormValues(attributes, "attributes", setValue) + resetFields(["exclude-session-state-from-auth-response"]) } /> )} - {protocol !== openIdConnect && ( - <> - - {t("clients-help:fineGrainSamlEndpointConfig")} - - save()} - reset={() => - convertToFormValues(attributes, "attributes", setValue) - } - /> - - )} - - {protocol === openIdConnect && ( <> - {t("clients-help:openIdConnectCompatibilityModes")} + {t("clients-help:advancedSettings" + toUpperCase(protocol!))} - save()} - reset={() => - resetFields(["exclude-session-state-from-auth-response"]) - } + reset={() => { + resetFields([ + "saml-assertion-lifespan", + "access-token-lifespan", + "tls-client-certificate-bound-access-tokens", + "pkce-code-challenge-method", + ]); + }} /> - )} - <> - - {t("clients-help:advancedSettings" + toUpperCase(protocol!))} - - save()} - reset={() => { - resetFields([ - "saml-assertion-lifespan", - "access-token-lifespan", - "tls-client-certificate-bound-access-tokens", - "pkce-code-challenge-method", - ]); - }} - /> - - <> - - {t("clients-help:authenticationOverrides")} - - save()} - reset={() => { - setValue( - "authenticationFlowBindingOverrides.browser", - authenticationFlowBindingOverrides?.browser - ); - setValue( - "authenticationFlowBindingOverrides.direct_grant", - authenticationFlowBindingOverrides?.direct_grant - ); - }} - /> - - + <> + + {t("clients-help:authenticationOverrides")} + + save()} + reset={() => { + setValue( + "authenticationFlowBindingOverrides.browser", + authenticationFlowBindingOverrides?.browser + ); + setValue( + "authenticationFlowBindingOverrides.direct_grant", + authenticationFlowBindingOverrides?.direct_grant + ); + }} + /> + + + ); }; diff --git a/src/clients/add/CapabilityConfig.tsx b/src/clients/add/CapabilityConfig.tsx index fcb8f57857..6275d0a748 100644 --- a/src/clients/add/CapabilityConfig.tsx +++ b/src/clients/add/CapabilityConfig.tsx @@ -44,6 +44,7 @@ export const CapabilityConfig = ({ control={control} render={({ onChange, value }) => ( ( ( ( ( ( ( ( { return ( <> - - - {t("clients-help:evaluateExplain")} - - -
- - } - > - - - setIsScopeOpen(!isScopeOpen)} + isOpen={isScopeOpen} + selections={selected} + onSelect={(_, value) => { + const option = value as string; + if (selected.includes(option)) { + if (option !== prefix) { + setSelected(selected.filter((item) => item !== option)); + } + } else { + setSelected([...selected, option]); } - } else { - setSelected([...selected, option]); - } - }} - aria-labelledby={t("scopeParameter")} - placeholderText={t("scopeParameterPlaceholder")} - > - {selectableScopes.map((option, index) => ( - - ))} - - - - - {selected.join(" ")} - - - - - + {selectableScopes.map((option, index) => ( + + ))} + + + + + {selected.join(" ")} + + + + + + } + > + setIsUserOpen(!isUserOpen)} - onFilter={(e) => { - const value = e?.target.value || ""; - setUserSearch(value); - return userItems; - }} - onClear={() => { - setUser(undefined); - setUserSearch(""); - }} - selections={[user]} - onSelect={(_, value) => { - setUser(value as UserRepresentation); - setUserSearch(""); - setIsUserOpen(false); - }} - isOpen={isUserOpen} - /> - -
+ + + + void; + onClose: () => void; +}; + +type ClientRole = ClientRepresentation & { + numberOfRoles: number; +}; + +const realmRole = { + name: "realmRoles", +} as ClientRepresentation; + +export const AddServiceAccountModal = ({ + clientId, + serviceAccountId, + onAssign, + onClose, +}: AddServiceAccountModalProps) => { + const { t } = useTranslation("clients"); + const adminClient = useAdminClient(); + const errorHandler = useErrorHandler(); + + const [clients, setClients] = 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(); + return ( + await Promise.all( + clients.map(async (client) => { + const roles = await adminClient.users.listAvailableClientRoleMappings( + { + id: serviceAccountId, + clientUniqueId: 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" + ); + } + const realmRoles = ( + await adminClient.users.listAvailableRealmRoleMappings({ + id: serviceAccountId, + }) + ).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) => + ( + await adminClient.users.listAvailableClientRoleMappings({ + id: serviceAccountId, + clientUniqueId: client.id!, + }) + ).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 ( + { + onAssign(selectedRows!); + onClose(); + }} + > + {t("assign")} + , + , + ]} + > + + + + {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/clients/service-account/ServiceAccount.tsx b/src/clients/service-account/ServiceAccount.tsx index 59719a7176..85ddc510b5 100644 --- a/src/clients/service-account/ServiceAccount.tsx +++ b/src/clients/service-account/ServiceAccount.tsx @@ -1,25 +1,50 @@ import React, { useContext, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Badge, Button, Checkbox, ToolbarItem } from "@patternfly/react-core"; +import { + AlertVariant, + Badge, + Button, + Checkbox, + ToolbarItem, +} from "@patternfly/react-core"; -import RoleRepresentation from "keycloak-admin/lib/defs/roleRepresentation"; +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"; type ServiceAccountProps = { clientId: string; }; -type Row = { - client: ClientRepresentation; - role: CompositeRole; +export type Row = { + client?: ClientRepresentation; + role: CompositeRole | RoleRepresentation; }; +export const ServiceRole = ({ role, client }: Row) => ( + <> + {client && ( + + {client.clientId} + + )} + {role.name} + +); + type CompositeRole = RoleRepresentation & { parent: RoleRepresentation; }; @@ -28,13 +53,20 @@ 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 loader = async () => { const serviceAccount = await adminClient.clients.getServiceAccountUser({ id: clientId, }); + setServiceAccountId(serviceAccount.id!); const effectiveRoles = await adminClient.users.listCompositeRealmRoleMappings( { id: serviceAccount.id! } ); @@ -65,7 +97,6 @@ export const ServiceAccount = ({ clientId }: ServiceAccountProps) => { }; const clientRolesFlat = clientRoles.map((row) => row.roles).flat(); - console.log(clientRolesFlat); const addInherentData = await (async () => Promise.all( @@ -99,59 +130,90 @@ export const ServiceAccount = ({ clientId }: ServiceAccountProps) => { }); }; - const RoleLink = ({ role, client }: Row) => ( + const assignRoles = async (rows: Row[]) => { + try { + const realmRoles = rows + .filter((row) => row.client === undefined) + .map((row) => row.role as RoleMappingPayload) + .flat(); + adminClient.users.addRealmRoleMappings({ + id: serviceAccountId, + roles: realmRoles, + }); + await Promise.all( + rows + .filter((row) => row.client !== undefined) + .map((row) => + adminClient.users.addClientRoleMappings({ + id: serviceAccountId, + clientUniqueId: row.client!.id!, + roles: [row.role as RoleMappingPayload], + }) + ) + ); + addAlert(t("roleMappingUpdatedSuccess"), AlertVariant.success); + refresh(); + } catch (error) { + addAlert( + t("roleMappingUpdatedError", { + error: error.response?.data?.errorMessage || error, + }), + AlertVariant.danger + ); + } + }; + return ( <> - {client && ( - - {client.clientId} - + {showAssign && ( + setShowAssign(false)} + /> )} - {role.name} + {}} + 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()], + }, + ]} + /> ); - - return ( - {}} - searchPlaceholderKey="clients:searchByName" - ariaLabelKey="clients:clientScopeList" - toolbarItem={ - <> - - - - - - - - } - columns={[ - { - name: "role.name", - displayKey: t("name"), - cellRenderer: RoleLink, - }, - { - name: "role.parent.name", - displayKey: t("inherentFrom"), - cellFormatters: [emptyFormatter()], - }, - { - name: "role.description", - displayKey: t("description"), - cellFormatters: [emptyFormatter()], - }, - ]} - /> - ); }; diff --git a/src/components/table-toolbar/KeycloakDataTable.tsx b/src/components/table-toolbar/KeycloakDataTable.tsx index a3e7e3735b..117d6b09bc 100644 --- a/src/components/table-toolbar/KeycloakDataTable.tsx +++ b/src/components/table-toolbar/KeycloakDataTable.tsx @@ -43,10 +43,12 @@ function DataTable({ ariaLabelKey, onSelect, canSelectAll, + ...props }: DataTableProps) { const { t } = useTranslation(); return ( ({ searchTypeComponent, toolbarItem, emptyState, + ...props }: DataListProps) { const { t } = useTranslation(); const [selected, setSelected] = useState([]); @@ -281,6 +284,7 @@ export function KeycloakDataTable({ > {!loading && (filteredData || rows).length > 0 && ( ({ ariaLabelKey={ariaLabelKey} /> )} - {!loading && rows.length === 0 && search !== "" && ( - - )} + {!loading && + rows.length === 0 && + search !== "" && + searchPlaceholderKey && ( + + )} {loading && } )}