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")}
-
-
-
+
+
+
+
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 && }
)}