diff --git a/cypress/integration/client_authorization_test.spec.ts b/cypress/integration/client_authorization_test.spec.ts new file mode 100644 index 0000000000..b54b98edf4 --- /dev/null +++ b/cypress/integration/client_authorization_test.spec.ts @@ -0,0 +1,99 @@ +import { + keycloakBefore, + keycloakBeforeEach, +} from "../support/util/keycloak_hooks"; +import AdminClient from "../support/util/AdminClient"; +import LoginPage from "../support/pages/LoginPage"; +import ListingPage from "../support/pages/admin_console/ListingPage"; +import Masthead from "../support/pages/admin_console/Masthead"; +import SidebarPage from "../support/pages/admin_console/SidebarPage"; +import AuthorizationTab from "../support/pages/admin_console/manage/clients/AuthorizationTab"; + +describe("Client authentication subtab", () => { + const adminClient = new AdminClient(); + const loginPage = new LoginPage(); + const listingPage = new ListingPage(); + const masthead = new Masthead(); + const sidebarPage = new SidebarPage(); + const authenticationTab = new AuthorizationTab(); + const clientId = + "client-authentication-" + (Math.random() + 1).toString(36).substring(7); + + before(() => { + adminClient.createClient({ + protocol: "openid-connect", + clientId, + publicClient: false, + authorizationServicesEnabled: true, + serviceAccountsEnabled: true, + standardFlowEnabled: true, + }); + keycloakBefore(); + loginPage.logIn(); + }); + + after(() => { + adminClient.deleteClient(clientId); + }); + + beforeEach(() => { + keycloakBeforeEach(); + sidebarPage.goToClients(); + listingPage.searchItem(clientId).goToItemDetails(clientId); + authenticationTab.goToAuthenticationTab(); + }); + + it("Should update the resource server settings", () => { + authenticationTab.setPolicy("DISABLED").saveSettings(); + masthead.checkNotificationMessage("Resource successfully updated"); + }); + + it("Should create a resource", () => { + authenticationTab.goToResourceSubTab(); + authenticationTab.assertDefaultResource(); + + authenticationTab + .goToCreateResource() + .fillResourceForm({ + name: "Resource", + displayName: "The display name", + type: "type", + uris: ["one", "two"], + }) + .save(); + + masthead.checkNotificationMessage("Resource created successfully"); + }); + + it("Should create a scope", () => { + authenticationTab.goToScopeSubTab(); + authenticationTab + .goToCreateScope() + .fillScopeForm({ + name: "The scope", + displayName: "Display something", + iconUri: "res://something", + }) + .save(); + + masthead.checkNotificationMessage( + "Authorization scope created successfully" + ); + authenticationTab.goToScopeSubTab(); + listingPage.itemExist("The scope"); + }); + + it("Should create a permission", () => { + authenticationTab.goToPermissionsSubTab(); + authenticationTab + .goToCreatePermission("resource") + .fillPermissionForm({ + name: "Permission name", + description: "Something describing this permission", + }) + .selectResource("Resource") + .save(); + + masthead.checkNotificationMessage("Successfully created the permission"); + }); +}); diff --git a/cypress/integration/clients_saml_test.spec.ts b/cypress/integration/clients_saml_test.spec.ts index b90d19643d..e1e945f224 100644 --- a/cypress/integration/clients_saml_test.spec.ts +++ b/cypress/integration/clients_saml_test.spec.ts @@ -5,7 +5,6 @@ import SidebarPage from "../support/pages/admin_console/SidebarPage"; import ModalUtils from "../support/util/ModalUtils"; import AdminClient from "../support/util/AdminClient"; import { keycloakBefore } from "../support/util/keycloak_hooks"; -import AuthenticationTab from "../support/pages/admin_console/manage/clients/AuthenticationTab"; const loginPage = new LoginPage(); const masthead = new Masthead(); @@ -110,55 +109,4 @@ describe("Clients SAML tests", () => { cy.findAllByTestId("certificate").should("have.length", 1); }); }); - - describe("Authentication tab", () => { - const clientName = "authenticationTabClient"; - const authenticationTab = new AuthenticationTab(); - beforeEach(() => { - keycloakBefore(); - loginPage.logIn(); - sidebarPage.goToClients(); - }); - - before(async () => { - await new AdminClient().createClient({ - protocol: "openid-connect", - clientId: clientName, - publicClient: false, - authorizationServicesEnabled: true, - serviceAccountsEnabled: true, - standardFlowEnabled: true, - }); - }); - - after(() => { - new AdminClient().deleteClient(clientName); - }); - - it("Should update the resource server settings", () => { - listingPage.searchItem(clientName).goToItemDetails(clientName); - authenticationTab.goToAuthenticationTab(); - authenticationTab.setPolicy("DISABLED").saveSettings(); - - masthead.checkNotificationMessage("Resource successfully updated"); - }); - - it("Should create a resource", () => { - listingPage.searchItem(clientName).goToItemDetails(clientName); - authenticationTab.goToAuthenticationTab().goToResourceSubTab(); - authenticationTab.assertDefaultResource(); - - authenticationTab - .goToCreateResource() - .fillResourceForm({ - name: "Resource", - displayName: "The display name", - type: "type", - uris: ["one", "two"], - }) - .save(); - - masthead.checkNotificationMessage("Resource created successfully"); - }); - }); }); diff --git a/cypress/support/pages/admin_console/manage/clients/AuthenticationTab.ts b/cypress/support/pages/admin_console/manage/clients/AuthorizationTab.ts similarity index 50% rename from cypress/support/pages/admin_console/manage/clients/AuthenticationTab.ts rename to cypress/support/pages/admin_console/manage/clients/AuthorizationTab.ts index 186c4cc84d..67328c8563 100644 --- a/cypress/support/pages/admin_console/manage/clients/AuthenticationTab.ts +++ b/cypress/support/pages/admin_console/manage/clients/AuthorizationTab.ts @@ -1,10 +1,19 @@ +import type PolicyRepresentation from "@keycloak/keycloak-admin-client/lib/defs/policyRepresentation"; import type ResourceRepresentation from "@keycloak/keycloak-admin-client/lib/defs/resourceRepresentation"; +import type ScopeRepresentation from "@keycloak/keycloak-admin-client/lib/defs/scopeRepresentation"; -export default class AuthenticationTab { +type PermissionType = "resource" | "scope"; + +export default class AuthorizationTab { private tabName = "#pf-tab-authorization-authorization"; private resourcesTabName = "#pf-tab-41-resources"; + private scopeTabName = "#pf-tab-42-scopes"; + private permissionsTabName = "#pf-tab-43-permissions"; private nameColumnPrefix = "name-column-"; private createResourceButton = "createResource"; + private createScopeButton = "no-authorization-scopes-empty-action"; + private createPermissionDropdown = "permissionCreateDropdown"; + private permissionResourceDropdown = "#resources"; goToAuthenticationTab() { cy.get(this.tabName).click(); @@ -16,8 +25,29 @@ export default class AuthenticationTab { return this; } + goToScopeSubTab() { + cy.get(this.scopeTabName).click(); + return this; + } + + goToPermissionsSubTab() { + cy.get(this.permissionsTabName).click(); + return this; + } + goToCreateResource() { - cy.findAllByTestId(this.createResourceButton).click(); + cy.findByTestId(this.createResourceButton).click(); + return this; + } + + goToCreateScope() { + cy.findByTestId(this.createScopeButton).click(); + return this; + } + + goToCreatePermission(type: PermissionType) { + cy.findByTestId(this.createPermissionDropdown).click(); + cy.findByTestId(`create-${type}`).click(); return this; } @@ -36,6 +66,28 @@ export default class AuthenticationTab { return this; } + fillScopeForm(scope: ScopeRepresentation) { + Object.entries(scope).map(([key, value]) => cy.get(`#${key}`).type(value)); + return this; + } + + fillPermissionForm(permission: PolicyRepresentation) { + Object.entries(permission).map(([key, value]) => + cy.get(`#${key}`).type(value) + ); + return this; + } + + selectResource(name: string) { + cy.get(this.permissionResourceDropdown) + .click() + .parent() + .parent() + .findByText(name) + .click(); + return this; + } + setPolicy(policyName: string) { cy.findByTestId(policyName).click(); return this; @@ -52,7 +104,7 @@ export default class AuthenticationTab { } pressCancel() { - cy.findAllByTestId("cancel").click(); + cy.findByTestId("cancel").click(); return this; } diff --git a/src/clients/ClientDetails.tsx b/src/clients/ClientDetails.tsx index 6f35a261eb..f6aa519d0e 100644 --- a/src/clients/ClientDetails.tsx +++ b/src/clients/ClientDetails.tsx @@ -58,6 +58,7 @@ import { toMapper } from "./routes/Mapper"; import { AuthorizationSettings } from "./authorization/Settings"; import { AuthorizationResources } from "./authorization/Resources"; import { AuthorizationScopes } from "./authorization/Scopes"; +import { AuthorizationPermissions } from "./authorization/Permissions"; type ClientDetailHeaderProps = { onChange: (value: boolean) => void; @@ -137,7 +138,7 @@ const ClientDetailHeader = ({ <> + {t("permissions")}} + > + + )} diff --git a/src/clients/authorization/DetailCell.tsx b/src/clients/authorization/DetailCell.tsx index 7912e7441b..9224dd8bb6 100644 --- a/src/clients/authorization/DetailCell.tsx +++ b/src/clients/authorization/DetailCell.tsx @@ -1,15 +1,10 @@ import React, { useState } from "react"; -import { useTranslation } from "react-i18next"; -import { - DescriptionList, - DescriptionListGroup, - DescriptionListTerm, - DescriptionListDescription, -} from "@patternfly/react-core"; +import { DescriptionList } from "@patternfly/react-core"; import type ResourceServerRepresentation from "@keycloak/keycloak-admin-client/lib/defs/resourceServerRepresentation"; import { KeycloakSpinner } from "../../components/keycloak-spinner/KeycloakSpinner"; import { useAdminClient, useFetch } from "../../context/auth/AdminClient"; +import { DetailDescription } from "./DetailDescription"; import "./detail-cell.css"; @@ -22,7 +17,6 @@ type DetailCellProps = { }; export const DetailCell = ({ id, clientId, uris }: DetailCellProps) => { - const { t } = useTranslation("clients"); const adminClient = useAdminClient(); const [scope, setScope] = useState(); const [permissions, setPermissions] = @@ -53,39 +47,13 @@ export const DetailCell = ({ id, clientId, uris }: DetailCellProps) => { return ( - - {t("uris")} - - {uris?.map((uri) => ( - - {uri} - - ))} - {uris?.length === 0 && {t("common:none")}} - - - - {t("scopes")} - - {scope.map((scope) => ( - - {scope.name} - - ))} - {scope.length === 0 && {t("common:none")}} - - - - {t("associatedPermissions")} - - {permissions.map((permission) => ( - - {permission.name} - - ))} - {permissions.length === 0 && {t("common:none")}} - - + + s.name} /> + p.name!} + /> ); }; diff --git a/src/clients/authorization/DetailDescription.tsx b/src/clients/authorization/DetailDescription.tsx new file mode 100644 index 0000000000..9e4f65c42a --- /dev/null +++ b/src/clients/authorization/DetailDescription.tsx @@ -0,0 +1,38 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { + DescriptionListGroup, + DescriptionListTerm, + DescriptionListDescription, +} from "@patternfly/react-core"; + +type DetailDescriptionProps = { + name: string; + array?: string[] | T[]; + convert?: (obj: T) => string; +}; + +export function DetailDescription({ + name, + array, + convert, +}: DetailDescriptionProps) { + const { t } = useTranslation("clients"); + return ( + + {t(name)} + + {array?.map((element) => { + const value = + typeof element === "string" ? element : convert!(element); + return ( + + {value} + + ); + })} + {array?.length === 0 && {t("common:none")}} + + + ); +} diff --git a/src/clients/authorization/EmptyPermissionsState.tsx b/src/clients/authorization/EmptyPermissionsState.tsx new file mode 100644 index 0000000000..9f1eb16040 --- /dev/null +++ b/src/clients/authorization/EmptyPermissionsState.tsx @@ -0,0 +1,104 @@ +import React from "react"; +import { useHistory } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { + EmptyState, + EmptyStateIcon, + Title, + EmptyStateBody, + Button, + Tooltip, +} from "@patternfly/react-core"; +import { PlusCircleIcon } from "@patternfly/react-icons"; + +import { PermissionType, toNewPermission } from "../routes/NewPermission"; +import { useRealm } from "../../context/realm-context/RealmContext"; +import { toUpperCase } from "../../util"; + +type EmptyButtonProps = { + permissionType: PermissionType; + disabled?: boolean; + clientId: string; +}; + +const EmptyButton = ({ + permissionType, + disabled = false, + clientId, +}: EmptyButtonProps) => { + const { t } = useTranslation("clients"); + const { realm } = useRealm(); + const history = useHistory(); + return ( + + ); +}; + +const TooltipEmptyButton = ({ + permissionType, + disabled, + ...props +}: EmptyButtonProps) => { + const { t } = useTranslation("clients"); + return disabled ? ( + + + + ) : ( + + ); +}; + +type EmptyPermissionsStateProps = { + clientId: string; + isResourceEnabled?: boolean; + isScopeEnabled?: boolean; +}; + +export const EmptyPermissionsState = ({ + clientId, + isResourceEnabled, + isScopeEnabled, +}: EmptyPermissionsStateProps) => { + const { t } = useTranslation("clients"); + return ( + + + + {t("emptyPermissions")} + + {t("emptyPermissionInstructions")} + +
+ +
+ ); +}; diff --git a/src/clients/authorization/PermissionDetails.tsx b/src/clients/authorization/PermissionDetails.tsx new file mode 100644 index 0000000000..183f9a06d1 --- /dev/null +++ b/src/clients/authorization/PermissionDetails.tsx @@ -0,0 +1,349 @@ +import React, { useState } from "react"; +import { Link, useHistory, useParams } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { Controller, FormProvider, useForm } from "react-hook-form"; +import { + ActionGroup, + AlertVariant, + Button, + ButtonVariant, + DropdownItem, + FormGroup, + PageSection, + Radio, + Switch, + TextArea, + TextInput, +} from "@patternfly/react-core"; + +import type PolicyRepresentation from "@keycloak/keycloak-admin-client/lib/defs/policyRepresentation"; +import type { NewPermissionParams } from "../routes/NewPermission"; +import { + PermissionDetailsParams, + toPermissionDetails, +} from "../routes/PermissionDetails"; +import { useAdminClient, useFetch } from "../../context/auth/AdminClient"; +import { useConfirmDialog } from "../../components/confirm-dialog/ConfirmDialog"; +import { ViewHeader } from "../../components/view-header/ViewHeader"; +import { FormAccess } from "../../components/form-access/FormAccess"; +import { useAlerts } from "../../components/alert/Alerts"; +import { toClient } from "../routes/Client"; +import { HelpItem } from "../../components/help-enabler/HelpItem"; +import { ResourcesPolicySelect } from "./ResourcesPolicySelect"; + +const DECISION_STRATEGIES = ["UNANIMOUS", "AFFIRMATIVE", "CONSENSUS"] as const; + +export default function PermissionDetails() { + const { t } = useTranslation("clients"); + + const form = useForm({ + shouldUnregister: false, + mode: "onChange", + }); + const { register, control, reset, errors, handleSubmit } = form; + + const history = useHistory(); + const { id, realm, permissionType, permissionId } = useParams< + NewPermissionParams & PermissionDetailsParams + >(); + + const adminClient = useAdminClient(); + const { addAlert, addError } = useAlerts(); + const [permission, setPermission] = useState(); + const [applyToResourceTypeFlag, setApplyToResourceTypeFlag] = useState(false); + + useFetch( + async () => { + if (permissionId) { + const r = await Promise.all([ + adminClient.clients.findOnePermission({ + id, + type: permissionType, + permissionId, + }), + adminClient.clients.getAssociatedResources({ + id, + permissionId, + }), + adminClient.clients.getAssociatedPolicies({ + id, + permissionId, + }), + ]); + + if (!r[0]) { + throw new Error(t("common:notFound")); + } + + return { + permission: r[0], + resources: r[1].map((p) => p._id), + policies: r[2].map((p) => p.id!), + }; + } + return {}; + }, + ({ permission, resources, policies }) => { + reset({ ...permission, resources, policies }); + if (permission && "resourceType" in permission) { + setApplyToResourceTypeFlag( + !!(permission as { resourceType: string }).resourceType + ); + } + setPermission({ ...permission, resources, policies }); + }, + [] + ); + + const save = async (permission: PolicyRepresentation) => { + try { + if (permissionId) { + await adminClient.clients.updatePermission( + { id, type: permissionType, permissionId }, + permission + ); + } else { + const result = await adminClient.clients.createPermission( + { id, type: permissionType }, + permission + ); + history.push( + toPermissionDetails({ + realm, + id, + permissionType, + permissionId: result.id!, + }) + ); + } + addAlert( + t((permissionId ? "update" : "create") + "PermissionSuccess"), + AlertVariant.success + ); + } catch (error) { + addError("clients:permissionSaveError", error); + } + }; + + const [toggleDeleteDialog, DeleteConfirm] = useConfirmDialog({ + titleKey: "clients:deletePermission", + messageKey: t("deletePermissionConfirm", { + permission: permission?.name, + }), + continueButtonVariant: ButtonVariant.danger, + continueButtonLabel: "clients:confirm", + onConfirm: async () => { + try { + await adminClient.clients.delPermission({ + id, + type: permissionType, + permissionId: permissionId, + }); + addAlert(t("permissionDeletedSuccess"), AlertVariant.success); + history.push(toClient({ realm, clientId: id, tab: "authorization" })); + } catch (error) { + addError("clients:permissionDeletedError", error); + } + }, + }); + + return ( + <> + + toggleDeleteDialog()} + > + {t("common:delete")} + , + ] + : undefined + } + /> + + + + + } + > + + + + } + validated={errors.description ? "error" : "default"} + helperTextInvalid={errors.description?.message} + > +