From 15baa43cfb865bb6b1de8d767b5c113024e26155 Mon Sep 17 00:00:00 2001 From: Erik Jan de Wit Date: Wed, 17 Nov 2021 09:27:56 +0100 Subject: [PATCH] Added initial "Authorisation" tabs Settings and Resources (#1524) --- src/clients/ClientDetails.tsx | 36 +++- src/clients/authorization/DetailCell.tsx | 90 +++++++++ src/clients/authorization/Resources.tsx | 223 ++++++++++++++++++++++ src/clients/authorization/Settings.tsx | 174 +++++++++++++++++ src/clients/authorization/detail-cell.css | 3 + src/clients/help.ts | 8 + src/clients/messages.ts | 27 +++ src/common-messages.ts | 1 + 8 files changed, 559 insertions(+), 3 deletions(-) create mode 100644 src/clients/authorization/DetailCell.tsx create mode 100644 src/clients/authorization/Resources.tsx create mode 100644 src/clients/authorization/Settings.tsx create mode 100644 src/clients/authorization/detail-cell.css diff --git a/src/clients/ClientDetails.tsx b/src/clients/ClientDetails.tsx index 9f8c98cdbc..a06e8fb9c0 100644 --- a/src/clients/ClientDetails.tsx +++ b/src/clients/ClientDetails.tsx @@ -58,6 +58,8 @@ import { MapperList } from "../client-scopes/details/MapperList"; import type { ProtocolMapperTypeRepresentation } from "@keycloak/keycloak-admin-client/lib/defs/serverInfoRepesentation"; import type ProtocolMapperRepresentation from "@keycloak/keycloak-admin-client/lib/defs/protocolMapperRepresentation"; import { toMapper } from "./routes/Mapper"; +import { AuthorizationSettings } from "./authorization/Settings"; +import { AuthorizationResources } from "./authorization/Resources"; type ClientDetailHeaderProps = { onChange: (value: boolean) => void; @@ -183,7 +185,8 @@ export default function ClientDetails() { const [changeAuthenticatorOpen, setChangeAuthenticatorOpen] = useState(false); const toggleChangeAuthenticator = () => setChangeAuthenticatorOpen(!changeAuthenticatorOpen); - const [activeTab2, setActiveTab2] = useState(30); + const [clientScopeSubTab, setClientScopeSubTab] = useState(30); + const [authorizationSubTab, setAuthorizationSubTab] = useState(40); const form = useForm({ shouldUnregister: false }); const { clientId } = useParams(); @@ -446,8 +449,8 @@ export default function ClientDetails() { title={{t("clientScopes")}} > setActiveTab2(key as number)} + activeKey={clientScopeSubTab} + onSelect={(_, key) => setClientScopeSubTab(key as number)} > )} + {client!.serviceAccountsEnabled && ( + {t("authorization")}} + > + setAuthorizationSubTab(key as number)} + > + {t("settings")}} + > + + + {t("resources")}} + > + + + + + )} {client!.serviceAccountsEnabled && ( { + const { t } = useTranslation("clients"); + const adminClient = useAdminClient(); + const [scope, setScope] = useState(); + const [permissions, setPermissions] = + useState(); + + useFetch( + () => + Promise.all([ + adminClient.clients.listScopesByResource({ + id: clientId, + resourceName: id, + }), + adminClient.clients.listPermissionsByResource({ + id: clientId, + resourceId: id, + }), + ]), + ([scopes, permissions]) => { + setScope(scopes); + setPermissions(permissions); + }, + [] + ); + return ( + + {uris?.length !== 0 && ( + + {t("uris")} + + {uris?.map((uri) => ( + + {uri} + + ))} + + + )} + {scope?.length !== 0 && ( + + {t("scopes")} + + {scope?.map((scope) => ( + + {scope.name} + + ))} + + + )} + {permissions?.length !== 0 && ( + + + {t("associatedPermissions")} + + + {permissions?.map((permission) => ( + + {permission.name} + + ))} + + + )} + + ); +}; diff --git a/src/clients/authorization/Resources.tsx b/src/clients/authorization/Resources.tsx new file mode 100644 index 0000000000..70bbea3800 --- /dev/null +++ b/src/clients/authorization/Resources.tsx @@ -0,0 +1,223 @@ +import React, { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + Alert, + AlertVariant, + Label, + PageSection, + Spinner, +} from "@patternfly/react-core"; +import { + ExpandableRowContent, + TableComposable, + Tbody, + Td, + Th, + Thead, + Tr, +} from "@patternfly/react-table"; + +import type ResourceServerRepresentation from "@keycloak/keycloak-admin-client/lib/defs/resourceServerRepresentation"; +import type ResourceRepresentation from "@keycloak/keycloak-admin-client/lib/defs/resourceRepresentation"; +import { useConfirmDialog } from "../../components/confirm-dialog/ConfirmDialog"; +import { PaginatingTableToolbar } from "../../components/table-toolbar/PaginatingTableToolbar"; +import { useAdminClient, useFetch } from "../../context/auth/AdminClient"; +import { useAlerts } from "../../components/alert/Alerts"; +import { DetailCell } from "./DetailCell"; + +type ResourcesProps = { + clientId: string; +}; + +type ExpandableResourceRepresentation = ResourceRepresentation & { + isExpanded: boolean; +}; + +export const AuthorizationResources = ({ clientId }: ResourcesProps) => { + const { t } = useTranslation("clients"); + const adminClient = useAdminClient(); + const { addAlert, addError } = useAlerts(); + const [resources, setResources] = + useState(); + const [selectedResource, setSelectedResource] = + useState(); + const [permissions, setPermission] = + useState(); + + const [key, setKey] = useState(0); + const refresh = () => setKey(key + 1); + + const [max, setMax] = useState(10); + const [first, setFirst] = useState(0); + + useFetch( + () => { + const params = { + first, + max, + deep: false, + }; + return adminClient.clients.listResources({ + ...params, + id: clientId, + }); + }, + (resources) => + setResources( + resources.map((resource) => ({ ...resource, isExpanded: false })) + ), + [key] + ); + + const UriRenderer = ({ row }: { row: ResourceRepresentation }) => ( + <> + {row.uris?.[0]}{" "} + {(row.uris?.length || 0) > 1 && ( + + )} + + ); + + const fetchPermissions = async (id: string) => { + return adminClient.clients.listPermissionsByResource({ + id: clientId, + resourceId: id, + }); + }; + + const [toggleDeleteDialog, DeleteConfirm] = useConfirmDialog({ + titleKey: "clients:deleteResource", + children: ( + <> + {t("deleteResourceConfirm")} + {permissions?.length && ( + +

+ {permissions.map((permission) => ( + + {permission.name} + + ))} +

+
+ )} + + ), + continueButtonLabel: "clients:confirm", + onConfirm: async () => { + try { + await adminClient.clients.delResource({ + id: clientId, + resourceId: selectedResource?._id!, + }); + addAlert(t("resourceDeletedSuccess"), AlertVariant.success); + refresh(); + } catch (error) { + addError("clients:resourceDeletedError", error); + } + }, + }); + + if (!resources) { + return ; + } + + return ( + + + { + setFirst(first); + setMax(max); + }} + > + + + + + {t("common:name")} + {t("common:type")} + {t("owner")} + {t("uris")} + + + + {resources.map((resource, rowIndex) => ( + + + { + const rows = resources.map((resource, index) => + index === rowIndex + ? { ...resource, isExpanded: !resource.isExpanded } + : resource + ); + setResources(rows); + }, + }} + /> + {resource.name} + {resource.type} + {resource.owner?.name} + + + + { + setSelectedResource(resource); + setPermission(await fetchPermissions(resource._id!)); + toggleDeleteDialog(); + }, + }, + { + title: t("createPermission"), + className: "pf-m-link", + isOutsideDropdown: true, + }, + ], + }} + > + + + + + {resource.isExpanded && ( + + )} + + + + + ))} + + + + ); +}; diff --git a/src/clients/authorization/Settings.tsx b/src/clients/authorization/Settings.tsx new file mode 100644 index 0000000000..325fd0cd5f --- /dev/null +++ b/src/clients/authorization/Settings.tsx @@ -0,0 +1,174 @@ +import React, { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Controller, useForm } from "react-hook-form"; +import { + Button, + Divider, + FormGroup, + PageSection, + Radio, + Spinner, + Switch, +} from "@patternfly/react-core"; + +import type ResourceServerRepresentation from "@keycloak/keycloak-admin-client/lib/defs/resourceServerRepresentation"; +import { useAdminClient, useFetch } from "../../context/auth/AdminClient"; +import { FormAccess } from "../../components/form-access/FormAccess"; +import { HelpItem } from "../../components/help-enabler/HelpItem"; +import { SaveReset } from "../advanced/SaveReset"; + +const POLICY_ENFORCEMENT_MODES = [ + "ENFORCING", + "PERMISSIVE", + "DISABLED", +] as const; +const DECISION_STRATEGY = ["UNANIMOUS", "AFFIRMATIVE"] as const; + +export const AuthorizationSettings = ({ clientId }: { clientId: string }) => { + const { t } = useTranslation("clients"); + const [resource, setResource] = useState(); + const { control, reset } = useForm({ + shouldUnregister: false, + }); + + const adminClient = useAdminClient(); + + useFetch( + () => adminClient.clients.getResourceServer({ id: clientId }), + (resource) => { + setResource(resource); + reset(resource); + }, + [] + ); + + if (!resource) { + return ; + } + + return ( + + + + } + > + + + + + } + fieldId="policyEnforcementMode" + hasNoPaddingTop + > + ( + <> + {POLICY_ENFORCEMENT_MODES.map((mode) => ( + onChange(mode)} + label={t(`policyEnforcementModes.${mode}`)} + className="pf-u-mb-md" + /> + ))} + + )} + /> + + + } + fieldId="decisionStrategy" + hasNoPaddingTop + > + ( + <> + {DECISION_STRATEGY.map((strategy) => ( + onChange(strategy)} + label={t(`decisionStrategies.${strategy}`)} + className="pf-u-mb-md" + /> + ))} + + )} + /> + + + } + > + ( + + )} + /> + + { + // another PR + }} + reset={() => reset(resource)} + /> + + + ); +}; diff --git a/src/clients/authorization/detail-cell.css b/src/clients/authorization/detail-cell.css new file mode 100644 index 0000000000..d862d810d1 --- /dev/null +++ b/src/clients/authorization/detail-cell.css @@ -0,0 +1,3 @@ +.keycloak_resource_details { + --pf-c-description-list--m-horizontal__term--width: 20ch; +} \ No newline at end of file diff --git a/src/clients/help.ts b/src/clients/help.ts index 88ac4bc800..ec0ae19c48 100644 --- a/src/clients/help.ts +++ b/src/clients/help.ts @@ -154,5 +154,13 @@ export default { "Applicable only if 'Consent Required' is on for this client. If this switch is off, the consent screen will contain just the consents corresponding to configured client scopes. If on, there will be also one item on the consent screen about this client itself.", consentScreenText: "Applicable only if 'Display Client On Consent Screen' is on for this client. Contains the text which will be on the consent screen about permissions specific just for this client.", + import: + "Import a JSON file containing authorization settings for this resource server.", + policyEnforcementMode: + "The policy enforcement mode dictates how policies are enforced when evaluating authorization requests. 'Enforcing' means requests are denied by default even when there is no policy associated with a given resource. 'Permissive' means requests are allowed even when there is no policy associated with a given resource. 'Disabled' completely disables the evaluation of policies and allows access to any resource.", + decisionStrategy: + "The decision strategy dictates how permissions are evaluated and how a final decision is obtained. 'Affirmative' means that at least one permission must evaluate to a positive decision in order to grant access to a resource and its scopes. 'Unanimous' means that all permissions must evaluate to a positive decision in order for the final decision to be also positive.", + allowRemoteResourceManagement: + "Should resources be managed remotely by the resource server? If false, resources can be managed only from this admin console.", }, }; diff --git a/src/clients/messages.ts b/src/clients/messages.ts index ea2bf9b503..52c888b428 100644 --- a/src/clients/messages.ts +++ b/src/clients/messages.ts @@ -46,6 +46,33 @@ export default { type: "Assigned type", protocol: "Protocol", }, + authorization: "Authorization", + settings: "Settings", + policyEnforcementMode: "Policy enforcement mode", + policyEnforcementModes: { + ENFORCING: "Enforcing", + PERMISSIVE: "Permissive", + DISABLED: "Disabled", + }, + decisionStrategy: "Decision strategy", + decisionStrategies: { + UNANIMOUS: "Unanimous", + AFFIRMATIVE: "Affirmative", + }, + resources: "Resources", + owner: "Owner", + uris: "URIs", + scopes: "Scopes", + createPermission: "Create permission", + deleteResource: "Permanently delete resource?", + deleteResourceConfirm: + "If you delete this resource, some permissions will be affected.", + deleteResourceWarning: + "The permissions below will be removed when they are no longer used by other resources:", + resourceDeletedSuccess: "The resource successfully deleted", + resourceDeletedError: "Could not remove the resource {{error}}", + associatedPermissions: "Associated permission", + allowRemoteResourceManagement: "Remote resource management", assignedClientScope: "Assigned client scope", assignedType: "Assigned type", hideInheritedRoles: "Hide inherited roles", diff --git a/src/common-messages.ts b/src/common-messages.ts index e3aab6c850..5008d32c3e 100644 --- a/src/common-messages.ts +++ b/src/common-messages.ts @@ -52,6 +52,7 @@ export default { show: "Show", hide: "Hide", showRemaining: "Show ${remaining}", + more: "{{count}} more", test: "Test", testConnection: "Test connection", name: "Name",