From 9d26a2301e501d19a795ad9a8a5ca4f5c57e2dd9 Mon Sep 17 00:00:00 2001 From: Agnieszka Gancarczyk Date: Thu, 28 Nov 2024 12:18:15 +0000 Subject: [PATCH] Introduced Permissions tab Signed-off-by: Agnieszka Gancarczyk --- .../admin/messages/messages_en.properties | 1 + js/apps/admin-ui/src/PageNav.tsx | 4 + js/apps/admin-ui/src/index.ts | 1 + .../src/permissions/PermissionsEvaluate.tsx | 345 +++++++++++++++ .../src/permissions/PermissionsExport.tsx | 104 +++++ .../src/permissions/PermissionsList.tsx | 396 ++++++++++++++++++ .../src/permissions/PermissionsPolicies.tsx | 359 ++++++++++++++++ .../src/permissions/PermissionsResources.tsx | 347 +++++++++++++++ .../src/permissions/PermissionsScopes.tsx | 330 +++++++++++++++ .../src/permissions/PermissionsSection.tsx | 268 ++++++++++++ .../src/permissions/PermissionsSettings.tsx | 161 +++++++ js/apps/admin-ui/src/permissions/routes.ts | 7 + .../src/permissions/routes/Permissions.tsx | 21 + .../permissions/routes/PermissionsTabs.tsx | 35 ++ js/apps/admin-ui/src/routes.tsx | 2 + 15 files changed, 2381 insertions(+) create mode 100644 js/apps/admin-ui/src/permissions/PermissionsEvaluate.tsx create mode 100644 js/apps/admin-ui/src/permissions/PermissionsExport.tsx create mode 100644 js/apps/admin-ui/src/permissions/PermissionsList.tsx create mode 100644 js/apps/admin-ui/src/permissions/PermissionsPolicies.tsx create mode 100644 js/apps/admin-ui/src/permissions/PermissionsResources.tsx create mode 100644 js/apps/admin-ui/src/permissions/PermissionsScopes.tsx create mode 100644 js/apps/admin-ui/src/permissions/PermissionsSection.tsx create mode 100644 js/apps/admin-ui/src/permissions/PermissionsSettings.tsx create mode 100644 js/apps/admin-ui/src/permissions/routes.ts create mode 100644 js/apps/admin-ui/src/permissions/routes/Permissions.tsx create mode 100644 js/apps/admin-ui/src/permissions/routes/PermissionsTabs.tsx diff --git a/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties b/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties index a78dfed077e1..c3a38cc60186 100644 --- a/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties +++ b/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties @@ -3305,3 +3305,4 @@ filterByMembershipType=Filter by Membership Type organizationsMembersListError=Could not fetch organization members\: {{error}} MANAGED=Managed UNMANAGED=Unmanaged +titlePermissions=Permissions \ No newline at end of file diff --git a/js/apps/admin-ui/src/PageNav.tsx b/js/apps/admin-ui/src/PageNav.tsx index 279f422cc3ef..738a56433248 100644 --- a/js/apps/admin-ui/src/PageNav.tsx +++ b/js/apps/admin-ui/src/PageNav.tsx @@ -128,6 +128,10 @@ export const PageNav = () => { + {isFeatureEnabled(Feature.AdminFineGrainedAuthzV2) && + realmRepresentation?.adminPermissionsEnabled && ( + + )} {isFeatureEnabled(Feature.DeclarativeUI) && pages?.map((p) => ( { + alias: string; + authScopes: string[]; + context: { + attributes: Record[]; + }; + resources?: Record[]; + client: FormFields; + user: string[]; +} + +export type AttributeType = { + key: string; + name: string; + custom?: boolean; + values?: { + [key: string]: string; + }[]; +}; + +type ClientSettingsProps = { + client: ClientRepresentation; + save: () => void; +}; + +export type AttributeForm = Omit< + EvaluateFormInputs, + "context" | "resources" +> & { + context: { + attributes?: KeyValueType[]; + }; + resources?: KeyValueType[]; +}; + +type Props = ClientSettingsProps & EvaluationResultRepresentation; + +export const PermissionsEvaluate = (props: Props) => { + const { hasAccess } = useAccess(); + + if (!hasAccess("view-users")) { + return ; + } + + return ; +}; + +const PermissionsEvaluateContent = ({ client }: Props) => { + const { adminClient } = useAdminClient(); + + const form = useForm({ mode: "onChange" }); + const { + reset, + trigger, + formState: { isValid }, + } = form; + const { t } = useTranslation(); + const { addError } = useAlerts(); + const realm = useRealm(); + const [isExpanded, setIsExpanded] = useState(false); + const [applyToResourceType, setApplyToResourceType] = useState(false); + const [resources, setResources] = useState([]); + const [scopes, setScopes] = useState([]); + const [evaluateResult, setEvaluateResult] = + useState(); + const [clientRoles, setClientRoles] = useState([]); + + useFetch( + () => adminClient.roles.find(), + (roles) => { + setClientRoles(roles); + }, + [], + ); + + useFetch( + () => + Promise.all([ + adminClient.clients.listResources({ + id: client.id!, + }), + adminClient.clients.listAllScopes({ + id: client.id!, + }), + ]), + ([resources, scopes]) => { + setResources(resources); + setScopes(scopes); + }, + [], + ); + + const evaluate = async () => { + if (!(await trigger())) { + return; + } + const formValues = form.getValues(); + const keys = keyValueToArray(formValues.resources as KeyValueType[]); + const resEval: ResourceEvaluation = { + roleIds: formValues.roleIds ?? [], + clientId: formValues.client.id!, + userId: formValues.user![0], + resources: resources + .filter((resource) => Object.keys(keys).includes(resource.name!)) + .map((r) => ({ + ...r, + scopes: r.scopes?.filter((s) => + Object.values(keys) + .flatMap((v) => v) + .includes(s.name!), + ), + })), + entitlements: false, + context: { + attributes: Object.fromEntries( + formValues.context.attributes + .filter((item) => item.key || item.value !== "") + .map(({ key, value }) => [key, value]), + ), + }, + }; + + try { + const evaluation = await adminClient.clients.evaluateResource( + { id: client.id!, realm: realm.realm }, + resEval, + ); + + setEvaluateResult(evaluation); + } catch (error) { + addError("evaluateError", error); + } + }; + + if (evaluateResult) { + return ( + setEvaluateResult(undefined)} + /> + ); + } + + return ( + + + + + {t("identityInformation")} + + + + + + role.name!)} + /> + + + + + + {t("permissions")} + + + + + } + > + setApplyToResourceType(val)} + aria-label={t("applyToResourceType")} + /> + + {!applyToResourceType ? ( + + } + fieldId="resourcesAndScopes" + > + ((item) => ({ + name: item.name!, + key: item._id!, + }))} + resources={resources} + name="resources" + /> + + ) : ( + <> + + s.name!)} + /> + + )} + setIsExpanded(!isExpanded)} + isExpanded={isExpanded} + > + + } + fieldId="contextualAttributes" + > + + + + + + + + + + + + + ); +}; diff --git a/js/apps/admin-ui/src/permissions/PermissionsExport.tsx b/js/apps/admin-ui/src/permissions/PermissionsExport.tsx new file mode 100644 index 000000000000..f33558b49ac7 --- /dev/null +++ b/js/apps/admin-ui/src/permissions/PermissionsExport.tsx @@ -0,0 +1,104 @@ +import type ClientRepresentation from "@keycloak/keycloak-admin-client/lib/defs/clientRepresentation"; +import type ResourceServerRepresentation from "@keycloak/keycloak-admin-client/lib/defs/resourceServerRepresentation"; +import { + TextAreaControl, + useAlerts, + useFetch, +} from "@keycloak/keycloak-ui-shared"; +import { + ActionGroup, + AlertVariant, + Button, + PageSection, +} from "@patternfly/react-core"; +import { saveAs } from "file-saver"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useAdminClient } from "../admin-client"; +import { FormAccess } from "../components/form/FormAccess"; +import { KeycloakSpinner } from "@keycloak/keycloak-ui-shared"; +import { prettyPrintJSON } from "../util"; + +export const PermissionsExport = ({ clientId }: ClientRepresentation) => { + const { adminClient } = useAdminClient(); + + const { t } = useTranslation(); + const { addAlert, addError } = useAlerts(); + + const [code, setCode] = useState(); + const [authorizationDetails, setAuthorizationDetails] = + useState(); + + useFetch( + () => + adminClient.clients.exportResource({ + id: clientId!, + }), + + (authDetails) => { + setCode(JSON.stringify(authDetails, null, 2)); + setAuthorizationDetails(authDetails); + }, + [], + ); + + const exportAuthDetails = () => { + try { + saveAs( + new Blob([prettyPrintJSON(authorizationDetails)], { + type: "application/json", + }), + "test-authz-config.json", + ); + addAlert(t("exportAuthDetailsSuccess"), AlertVariant.success); + } catch (error) { + addError("exportAuthDetailsError", error); + } + }; + + if (!code) { + return ; + } + + return ( + + + + + + + + + + ); +}; diff --git a/js/apps/admin-ui/src/permissions/PermissionsList.tsx b/js/apps/admin-ui/src/permissions/PermissionsList.tsx new file mode 100644 index 000000000000..c2bdba8673c5 --- /dev/null +++ b/js/apps/admin-ui/src/permissions/PermissionsList.tsx @@ -0,0 +1,396 @@ +import type PolicyProviderRepresentation from "@keycloak/keycloak-admin-client/lib/defs/policyProviderRepresentation"; +import type PolicyRepresentation from "@keycloak/keycloak-admin-client/lib/defs/policyRepresentation"; +import { + ListEmptyState, + PaginatingTableToolbar, + useAlerts, + useFetch, +} from "@keycloak/keycloak-ui-shared"; +import { + Alert, + AlertVariant, + ButtonVariant, + DescriptionList, + Divider, + Dropdown, + DropdownItem, + DropdownList, + MenuToggle, + PageSection, + ToolbarItem, +} from "@patternfly/react-core"; +import { + ExpandableRowContent, + Table, + Tbody, + Td, + Th, + Thead, + Tr, +} from "@patternfly/react-table"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Link, useNavigate } from "react-router-dom"; +import { useAdminClient } from "../admin-client"; +import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog"; +import { KeycloakSpinner } from "@keycloak/keycloak-ui-shared"; +import { useRealm } from "../context/realm-context/RealmContext"; +import useToggle from "../utils/useToggle"; +import { toNewPermission } from "../clients/routes/NewPermission"; +import { toPermissionDetails } from "../clients/routes/PermissionDetails"; +import { toPolicyDetails } from "../clients/routes/PolicyDetails"; +import { DetailDescriptionLink } from "../clients/authorization/DetailDescription"; +import { EmptyPermissionsState } from "../clients/authorization/EmptyPermissionsState"; +import { MoreLabel } from "../clients/authorization/MoreLabel"; +import { + SearchDropdown, + SearchForm, +} from "../clients/authorization/SearchDropdown"; + +import "../clients/authorization/permissions.css"; + +type PermissionsProps = { + clientId: string; + isDisabled?: boolean; +}; + +type ExpandablePolicyRepresentation = PolicyRepresentation & { + associatedPolicies?: PolicyRepresentation[]; + isExpanded: boolean; +}; + +const AssociatedPoliciesRenderer = ({ + row, +}: { + row: ExpandablePolicyRepresentation; +}) => { + return ( + <> + {row.associatedPolicies?.[0]?.name || "—"}{" "} + + + ); +}; + +export const PermissionsList = ({ + clientId, + isDisabled = false, +}: PermissionsProps) => { + const { adminClient } = useAdminClient(); + + const { t } = useTranslation(); + const navigate = useNavigate(); + const { addAlert, addError } = useAlerts(); + const { realm } = useRealm(); + + const [permissions, setPermissions] = + useState(); + const [selectedPermission, setSelectedPermission] = + useState(); + const [policyProviders, setPolicyProviders] = + useState(); + const [disabledCreate, setDisabledCreate] = useState<{ + resources: boolean; + scopes: boolean; + }>(); + const [createOpen, toggleCreate] = useToggle(); + const [search, setSearch] = useState({}); + + const [key, setKey] = useState(0); + const refresh = () => setKey(key + 1); + + const [max, setMax] = useState(10); + const [first, setFirst] = useState(0); + + useFetch( + async () => { + const permissions = await adminClient.clients.findPermissions({ + first, + max: max + 1, + id: clientId, + ...search, + }); + + return await Promise.all( + permissions.map(async (permission) => { + const associatedPolicies = + await adminClient.clients.getAssociatedPolicies({ + id: clientId, + permissionId: permission.id!, + }); + + return { + ...permission, + associatedPolicies, + isExpanded: false, + }; + }), + ); + }, + setPermissions, + [key, search, first, max], + ); + + useFetch( + async () => { + const params = { + first: 0, + max: 1, + }; + const [policies, resources, scopes] = await Promise.all([ + adminClient.clients.listPolicyProviders({ + id: clientId, + }), + adminClient.clients.listResources({ ...params, id: clientId }), + adminClient.clients.listAllScopes({ ...params, id: clientId }), + ]); + return { + policies: policies.filter( + (p) => p.type === "resource" || p.type === "scope", + ), + resources: resources.length !== 1, + scopes: scopes.length !== 1, + }; + }, + ({ policies, resources, scopes }) => { + setPolicyProviders(policies); + setDisabledCreate({ resources, scopes }); + }, + [], + ); + + const [toggleDeleteDialog, DeleteConfirm] = useConfirmDialog({ + titleKey: "deletePermission", + messageKey: t("deletePermissionConfirm", { + permission: selectedPermission?.name, + }), + continueButtonVariant: ButtonVariant.danger, + continueButtonLabel: "confirm", + onConfirm: async () => { + try { + await adminClient.clients.delPermission({ + id: clientId, + type: selectedPermission?.type!, + permissionId: selectedPermission?.id!, + }); + addAlert(t("permissionDeletedSuccess"), AlertVariant.success); + refresh(); + } catch (error) { + addError("permissionDeletedError", error); + } + }, + }); + + if (!permissions) { + return ; + } + + const noData = permissions.length === 0; + const searching = Object.keys(search).length !== 0; + return ( + + + {(!noData || searching) && ( + { + setFirst(first); + setMax(max); + }} + toolbarItem={ + <> + + + + + ( + + {t("createPermission")} + + )} + isOpen={createOpen} + > + + + navigate( + toNewPermission({ + realm, + id: clientId, + permissionType: "resource", + }), + ) + } + > + {t("createResourceBasedPermission")} + + + + navigate( + toNewPermission({ + realm, + id: clientId, + permissionType: "scope", + }), + ) + } + > + {t("createScopeBasedPermission")} + {disabledCreate?.scopes && ( + + )} + + + + + + } + > + {!noData && ( + + + + + + + + + + {permissions.map((permission, rowIndex) => ( + + + + + + + + + + + + + ))} +
{t("name")}{t("type")}{t("associatedPolicy")}{t("description")}
{ + const rows = permissions.map((p, index) => + index === rowIndex + ? { ...p, isExpanded: !p.isExpanded } + : p, + ); + setPermissions(rows); + }, + }} + /> + + + {permission.name} + + + { + policyProviders?.find((p) => p.type === permission.type) + ?.name + } + + + {permission.description || "—"} { + setSelectedPermission(permission); + toggleDeleteDialog(); + }, + }, + ], + }} + >
+ + + {permission.isExpanded && ( + + p.name!} + link={(p) => + toPolicyDetails({ + id: clientId, + realm, + policyId: p.id!, + policyType: p.type!, + }) + } + /> + + )} + +
+ )} +
+ )} + {noData && !searching && ( + + )} + {noData && searching && ( + + )} +
+ ); +}; diff --git a/js/apps/admin-ui/src/permissions/PermissionsPolicies.tsx b/js/apps/admin-ui/src/permissions/PermissionsPolicies.tsx new file mode 100644 index 000000000000..90184d2a9166 --- /dev/null +++ b/js/apps/admin-ui/src/permissions/PermissionsPolicies.tsx @@ -0,0 +1,359 @@ +import type PolicyProviderRepresentation from "@keycloak/keycloak-admin-client/lib/defs/policyProviderRepresentation"; +import type PolicyRepresentation from "@keycloak/keycloak-admin-client/lib/defs/policyRepresentation"; +import { + ListEmptyState, + PaginatingTableToolbar, + useAlerts, + useFetch, +} from "@keycloak/keycloak-ui-shared"; +import { + Alert, + AlertVariant, + Button, + DescriptionList, + PageSection, + ToolbarItem, +} from "@patternfly/react-core"; +import { + ExpandableRowContent, + Table, + Tbody, + Td, + Th, + Thead, + Tr, +} from "@patternfly/react-table"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Link, useNavigate } from "react-router-dom"; +import { useAdminClient } from "../admin-client"; +import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog"; +import { KeycloakSpinner } from "@keycloak/keycloak-ui-shared"; +import { useRealm } from "../context/realm-context/RealmContext"; +import { toUpperCase } from "../util"; +import useToggle from "../utils/useToggle"; +import { toCreatePolicy } from "../clients/routes/NewPolicy"; +import { toPermissionDetails } from "../clients/routes/PermissionDetails"; +import { toPolicyDetails } from "../clients/routes/PolicyDetails"; +import { DetailDescriptionLink } from "../clients/authorization/DetailDescription"; +import { MoreLabel } from "../clients/authorization/MoreLabel"; +import { NewPolicyDialog } from "../clients/authorization/NewPolicyDialog"; +import { + SearchDropdown, + SearchForm, +} from "../clients/authorization/SearchDropdown"; + +type PoliciesProps = { + clientId: string; + isDisabled?: boolean; +}; + +type ExpandablePolicyRepresentation = PolicyRepresentation & { + dependentPolicies?: PolicyRepresentation[]; + isExpanded: boolean; +}; + +const DependentPoliciesRenderer = ({ + row, +}: { + row: ExpandablePolicyRepresentation; +}) => { + return ( + <> + {row.dependentPolicies?.[0]?.name}{" "} + + + ); +}; + +export const PermissionsPolicies = ({ + clientId, + isDisabled = false, +}: PoliciesProps) => { + const { adminClient } = useAdminClient(); + + const { t } = useTranslation(); + const { addAlert, addError } = useAlerts(); + const { realm } = useRealm(); + const navigate = useNavigate(); + + const [policies, setPolicies] = useState(); + const [selectedPolicy, setSelectedPolicy] = + useState(); + const [policyProviders, setPolicyProviders] = + useState(); + + const [key, setKey] = useState(0); + const refresh = () => setKey(key + 1); + + const [max, setMax] = useState(10); + const [first, setFirst] = useState(0); + const [search, setSearch] = useState({}); + const [newDialog, toggleDialog] = useToggle(); + + useFetch( + async () => { + const policies = await adminClient.clients.listPolicies({ + first, + max: max + 1, + id: clientId, + permission: "false", + ...search, + }); + + return await Promise.all([ + adminClient.clients.listPolicyProviders({ id: clientId }), + ...(policies || []).map(async (policy) => { + const dependentPolicies = + await adminClient.clients.listDependentPolicies({ + id: clientId, + policyId: policy.id!, + }); + + return { + ...policy, + dependentPolicies, + isExpanded: false, + }; + }), + ]); + }, + ([providers, ...policies]) => { + setPolicyProviders( + providers.filter((p) => p.type !== "resource" && p.type !== "scope"), + ); + setPolicies(policies); + }, + [key, search, first, max], + ); + + const [toggleDeleteDialog, DeleteConfirm] = useConfirmDialog({ + titleKey: "deletePolicy", + children: ( + <> + {t("deletePolicyConfirm")} + {selectedPolicy?.dependentPolicies && + selectedPolicy.dependentPolicies.length > 0 && ( + +

+ {selectedPolicy.dependentPolicies.map((policy) => ( + + {policy.name} + + ))} +

+
+ )} + + ), + continueButtonLabel: "confirm", + onConfirm: async () => { + try { + await adminClient.clients.delPolicy({ + id: clientId, + policyId: selectedPolicy?.id!, + }); + addAlert(t("policyDeletedSuccess"), AlertVariant.success); + refresh(); + } catch (error) { + addError("policyDeletedError", error); + } + }, + }); + + if (!policies) { + return ; + } + + const noData = policies.length === 0; + const searching = Object.keys(search).length !== 0; + return ( + + + {(!noData || searching) && ( + <> + {newDialog && ( + + navigate( + toCreatePolicy({ id: clientId, realm, policyType: p.type! }), + ) + } + toggleDialog={toggleDialog} + /> + )} + + { + setFirst(first); + setMax(max); + }} + toolbarItem={ + <> + + + + + + + + } + > + {!noData && ( + + + + + + + + + + {policies.map((policy, rowIndex) => ( + + + + + + + {!isDisabled && ( + + + + + + ))} +
{t("name")}{t("type")}{t("dependentPermission")}{t("description")}
{ + const rows = policies.map((policy, index) => + index === rowIndex + ? { ...policy, isExpanded: !policy.isExpanded } + : policy, + ); + setPolicies(rows); + }, + }} + /> + + + {policy.name} + + {toUpperCase(policy.type!)} + + {policy.description} { + setSelectedPolicy(policy); + toggleDeleteDialog(); + }, + }, + ], + }} + /> + )} +
+ + + {policy.isExpanded && ( + + p.name!} + link={(permission) => + toPermissionDetails({ + realm, + id: clientId, + permissionId: permission.id!, + permissionType: permission.type!, + }) + } + /> + + )} + +
+ )} +
+ + )} + {noData && searching && ( + + )} + {noData && !searching && ( + <> + {newDialog && ( + p.type !== "aggregate", + )} + onSelect={(p) => + navigate( + toCreatePolicy({ id: clientId, realm, policyType: p.type! }), + ) + } + toggleDialog={toggleDialog} + /> + )} + + + )} +
+ ); +}; diff --git a/js/apps/admin-ui/src/permissions/PermissionsResources.tsx b/js/apps/admin-ui/src/permissions/PermissionsResources.tsx new file mode 100644 index 000000000000..915a5abe96f3 --- /dev/null +++ b/js/apps/admin-ui/src/permissions/PermissionsResources.tsx @@ -0,0 +1,347 @@ +import type ResourceRepresentation from "@keycloak/keycloak-admin-client/lib/defs/resourceRepresentation"; +import type ResourceServerRepresentation from "@keycloak/keycloak-admin-client/lib/defs/resourceServerRepresentation"; +import { + ListEmptyState, + PaginatingTableToolbar, + useAlerts, + useFetch, +} from "@keycloak/keycloak-ui-shared"; +import { + Alert, + AlertVariant, + Button, + PageSection, + ToolbarItem, +} from "@patternfly/react-core"; +import { + ExpandableRowContent, + Table, + TableText, + Tbody, + Td, + Th, + Thead, + Tr, +} from "@patternfly/react-table"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Link, useNavigate } from "react-router-dom"; +import { useAdminClient } from "../admin-client"; +import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog"; +import { KeycloakSpinner } from "@keycloak/keycloak-ui-shared"; +import { useRealm } from "../context/realm-context/RealmContext"; +import { MoreLabel } from "../clients/authorization/MoreLabel"; +import { + SearchDropdown, + SearchForm, +} from "../clients/authorization/SearchDropdown"; +import { toResourceDetails } from "../clients/routes/Resource"; +import { toNewPermission } from "../clients/routes/NewPermission"; +import { DetailCell } from "../clients/authorization/DetailCell"; +import { toCreateResource } from "../clients/routes/NewResource"; + +type ResourcesProps = { + clientId: string; + isDisabled?: boolean; +}; + +type ExpandableResourceRepresentation = ResourceRepresentation & { + isExpanded: boolean; +}; + +const UriRenderer = ({ row }: { row: ResourceRepresentation }) => ( + + {row.uris?.[0]} + +); + +export const PermissionsResources = ({ + clientId, + isDisabled = false, +}: ResourcesProps) => { + const { adminClient } = useAdminClient(); + + const { t } = useTranslation(); + const navigate = useNavigate(); + const { addAlert, addError } = useAlerts(); + const { realm } = useRealm(); + + 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); + const [search, setSearch] = useState({}); + + useFetch( + () => { + const params = { + first, + max: max + 1, + deep: false, + ...search, + }; + return adminClient.clients.listResources({ + ...params, + id: clientId, + }); + }, + (resources) => + setResources( + resources.map((resource) => ({ ...resource, isExpanded: false })), + ), + [key, search, first, max], + ); + + const fetchPermissions = async (id: string) => { + return adminClient.clients.listPermissionsByResource({ + id: clientId, + resourceId: id, + }); + }; + + const [toggleDeleteDialog, DeleteConfirm] = useConfirmDialog({ + titleKey: "deleteResource", + children: ( + <> + {t("deleteResourceConfirm")} + {permissions?.length && ( + +

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

+
+ )} + + ), + continueButtonLabel: "confirm", + onConfirm: async () => { + try { + await adminClient.clients.delResource({ + id: clientId, + resourceId: selectedResource?._id!, + }); + addAlert(t("resourceDeletedSuccess"), AlertVariant.success); + refresh(); + } catch (error) { + addError("resourceDeletedError", error); + } + }, + }); + + if (!resources) { + return ; + } + + const noData = resources.length === 0; + const searching = Object.keys(search).length !== 0; + return ( + + + {(!noData || searching) && ( + { + setFirst(first); + setMax(max); + }} + toolbarItem={ + <> + + + + + + + + + } + > + {!noData && ( + + + + + + + + + {!isDisabled && ( + <> + + + {resources.map((resource, rowIndex) => ( + + + + + + + + {!isDisabled && ( + <> + + + + + + + ))} +
{t("name")}{t("displayName")}{t("type")}{t("owner")}{t("uris")}
{ + const rows = resources.map((resource, index) => + index === rowIndex + ? { + ...resource, + isExpanded: !resource.isExpanded, + } + : resource, + ); + setResources(rows); + }, + }} + /> + + + + {resource.name} + + + + + {resource.displayName} + + + + {resource.type} + + + + {resource.owner?.name} + + + + + + { + setSelectedResource(resource); + setPermission( + await fetchPermissions(resource._id!), + ); + toggleDeleteDialog(); + }, + }, + ], + }} + /> + + )} +
+ + + {resource.isExpanded && ( + + )} + +
+ )} +
+ )} + {noData && searching && ( + + )} + {noData && !searching && ( + + navigate(toCreateResource({ realm, id: clientId })) + } + /> + )} +
+ ); +}; diff --git a/js/apps/admin-ui/src/permissions/PermissionsScopes.tsx b/js/apps/admin-ui/src/permissions/PermissionsScopes.tsx new file mode 100644 index 000000000000..255de89cc553 --- /dev/null +++ b/js/apps/admin-ui/src/permissions/PermissionsScopes.tsx @@ -0,0 +1,330 @@ +import type PolicyRepresentation from "@keycloak/keycloak-admin-client/lib/defs/policyRepresentation"; +import type ScopeRepresentation from "@keycloak/keycloak-admin-client/lib/defs/scopeRepresentation"; +import { + ListEmptyState, + PaginatingTableToolbar, + useFetch, +} from "@keycloak/keycloak-ui-shared"; +import { + Button, + DescriptionList, + PageSection, + ToolbarItem, +} from "@patternfly/react-core"; +import { + ExpandableRowContent, + Table, + Tbody, + Td, + Th, + Thead, + Tr, +} from "@patternfly/react-table"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Link, useNavigate } from "react-router-dom"; +import { useAdminClient } from "../admin-client"; +import { KeycloakSpinner } from "@keycloak/keycloak-ui-shared"; +import { useRealm } from "../context/realm-context/RealmContext"; +import useToggle from "../utils/useToggle"; +import { toNewPermission } from "../clients/routes/NewPermission"; +import { toNewScope } from "../clients/routes/NewScope"; +import { toPermissionDetails } from "../clients/routes/PermissionDetails"; +import { toResourceDetails } from "../clients/routes/Resource"; +import { toScopeDetails } from "../clients/routes/Scope"; +import { DeleteScopeDialog } from "../clients/authorization/DeleteScopeDialog"; +import { DetailDescriptionLink } from "../clients/authorization/DetailDescription"; + +type ScopesProps = { + clientId: string; + isDisabled?: boolean; +}; + +export type PermissionScopeRepresentation = ScopeRepresentation & { + permissions?: PolicyRepresentation[]; + isLoaded: boolean; +}; + +type ExpandableRow = { + id: string; + isExpanded: boolean; +}; + +export const PermissionsScopes = ({ + clientId, + isDisabled = false, +}: ScopesProps) => { + const { adminClient } = useAdminClient(); + + const { t } = useTranslation(); + const navigate = useNavigate(); + const { realm } = useRealm(); + + const [deleteDialog, toggleDeleteDialog] = useToggle(); + const [scopes, setScopes] = useState(); + const [selectedScope, setSelectedScope] = + useState(); + const [collapsed, setCollapsed] = useState([]); + + const [key, setKey] = useState(0); + const refresh = () => setKey(key + 1); + + const [max, setMax] = useState(10); + const [first, setFirst] = useState(0); + const [search, setSearch] = useState(""); + + useFetch( + () => { + const params = { + first, + max: max + 1, + deep: false, + name: search, + }; + return adminClient.clients.listAllScopes({ + ...params, + id: clientId, + }); + }, + (scopes) => { + setScopes(scopes.map((s) => ({ ...s, isLoaded: false }))); + setCollapsed(scopes.map((s) => ({ id: s.id!, isExpanded: false }))); + }, + [key, search, first, max], + ); + + const getScope = (id: string) => scopes?.find((scope) => scope.id === id)!; + const isExpanded = (id: string | undefined) => + collapsed.find((c) => c.id === id)?.isExpanded || false; + + useFetch( + () => { + const newlyOpened = collapsed + .filter((row) => row.isExpanded) + .map(({ id }) => getScope(id)) + .filter((s) => !s.isLoaded); + + return Promise.all( + newlyOpened.map(async (scope) => { + const [resources, permissions] = await Promise.all([ + adminClient.clients.listAllResourcesByScope({ + id: clientId, + scopeId: scope.id!, + }), + adminClient.clients.listAllPermissionsByScope({ + id: clientId, + scopeId: scope.id!, + }), + ]); + + return { + ...scope, + resources, + permissions, + isLoaded: true, + }; + }), + ); + }, + (resourcesScopes) => { + let result = [...(scopes || [])]; + resourcesScopes.forEach((resourceScope) => { + const index = scopes?.findIndex( + (scope) => resourceScope.id === scope.id, + )!; + result = [ + ...result.slice(0, index), + resourceScope, + ...result.slice(index + 1), + ]; + }); + + setScopes(result); + }, + [collapsed], + ); + + if (!scopes) { + return ; + } + + const noData = scopes.length === 0; + const searching = search !== ""; + return ( + + + {(!noData || searching) && ( + { + setFirst(first); + setMax(max); + }} + inputGroupName="search" + inputGroupPlaceholder={t("searchByName")} + inputGroupOnEnter={setSearch} + toolbarItem={ + + + + } + > + {!noData && ( + + + + + + + + {scopes.map((scope, rowIndex) => ( + + + + + + + + + + + ))} +
{t("name")}{t("displayName")}
{ + setCollapsed([ + ...collapsed.slice(0, index), + { id: scope.id!, isExpanded }, + ...collapsed.slice(index + 1), + ]); + }, + }} + /> + + + {scope.name} + + {scope.displayName} + + { + setSelectedScope(scope); + toggleDeleteDialog(); + }, + }, + ], + }} + /> +
+ + + {isExpanded(scope.id) && scope.isLoaded ? ( + + r.name!} + link={(r) => + toResourceDetails({ + id: clientId, + realm, + resourceId: r._id!, + }) + } + /> + p.name!} + link={(p) => + toPermissionDetails({ + id: clientId, + realm, + permissionId: p.id!, + permissionType: p.type!, + }) + } + /> + + ) : ( + + )} + +
+ )} +
+ )} + {noData && !searching && ( + navigate(toNewScope({ id: clientId, realm }))} + primaryActionText={t("createAuthorizationScope")} + /> + )} + {noData && searching && ( + + )} +
+ ); +}; diff --git a/js/apps/admin-ui/src/permissions/PermissionsSection.tsx b/js/apps/admin-ui/src/permissions/PermissionsSection.tsx new file mode 100644 index 000000000000..6daf3852b0b2 --- /dev/null +++ b/js/apps/admin-ui/src/permissions/PermissionsSection.tsx @@ -0,0 +1,268 @@ +import type ClientRepresentation from "@keycloak/keycloak-admin-client/lib/defs/clientRepresentation"; +import { useAlerts, useFetch } from "@keycloak/keycloak-ui-shared"; +import { useState } from "react"; +import { useAdminClient } from "../admin-client"; +import { ViewHeader } from "../components/view-header/ViewHeader"; +import { PermissionsResources } from "./PermissionsResources"; +import { + RoutableTabs, + useRoutableTab, +} from "../components/routable-tabs/RoutableTabs"; +import { + PermissionsTabs, + toPermissionsTabs, +} from "../permissions/routes/PermissionsTabs"; +import { + AlertVariant, + PageSection, + Tab, + TabTitleText, +} from "@patternfly/react-core"; +import { PermissionsExport } from "../permissions/PermissionsExport"; +import { PermissionsEvaluate } from "../permissions/PermissionsEvaluate"; +import { PermissionsList } from "../permissions/PermissionsList"; +import { PermissionsPolicies } from "../permissions/PermissionsPolicies"; +import { PermissionsScopes } from "../permissions/PermissionsScopes"; +import { PermissionsSettings } from "../permissions/PermissionsSettings"; +import { useRealm } from "../context/realm-context/RealmContext"; +import { useAccess } from "../context/access/Access"; +import { useTranslation } from "react-i18next"; +import { FormProvider, useForm, useWatch } from "react-hook-form"; +import { FormFields, SaveOptions } from "../clients/ClientDetails"; +import { + convertAttributeNameToForm, + convertFormValuesToObject, + convertToFormValues, +} from "../util"; +import { ConfirmDialogModal } from "../components/confirm-dialog/ConfirmDialog"; +import { KeyValueType } from "../components/key-value-form/key-value-convert"; +import useToggle from "../utils/useToggle"; + +export default function PermissionsSection() { + const { adminClient } = useAdminClient(); + const { t } = useTranslation(); + const { realm } = useRealm(); + const { hasAccess } = useAccess(); + const { addAlert, addError } = useAlerts(); + const [realmManagementClient, setRealmManagementClient] = useState< + ClientRepresentation | undefined + >(); + const [changeAuthenticatorOpen, toggleChangeAuthenticatorOpen] = useToggle(); + const form = useForm(); + + const usePermissionsTabs = (tab: PermissionsTabs) => + useRoutableTab( + toPermissionsTabs({ + realm, + tab, + }), + ); + + const clientAuthenticatorType = useWatch({ + control: form.control, + name: "clientAuthenticatorType", + defaultValue: "client-secret", + }); + + const hasManageAuthorization = hasAccess("manage-authorization"); + const hasViewUsers = hasAccess("view-users"); + const permissionsSettingsTab = usePermissionsTabs("settings"); + const permissionsResourcesTab = usePermissionsTabs("resources"); + const permissionsScopesTab = usePermissionsTabs("scopes"); + const permissionsPoliciesTab = usePermissionsTabs("policies"); + const permissionsPermissionsTab = usePermissionsTabs("permissions"); + const permissionsEvaluateTab = usePermissionsTabs("evaluate"); + const permissionsExportTab = usePermissionsTabs("export"); + + useFetch( + async () => { + const clients = await adminClient.clients.find(); + return clients; + }, + (clients) => { + const realmManagementClient = clients.find( + (client) => client.clientId === "realm-management", + ); + setRealmManagementClient(realmManagementClient!); + }, + [], + ); + + const setupForm = (client: ClientRepresentation) => { + form.reset({ ...client }); + convertToFormValues(client, form.setValue); + if (client.attributes?.["acr.loa.map"]) { + form.setValue( + convertAttributeNameToForm("attributes.acr.loa.map"), + // @ts-ignore + Object.entries(JSON.parse(client.attributes["acr.loa.map"])).flatMap( + ([key, value]) => ({ key, value }), + ), + ); + } + }; + + const save = async ( + { confirmed = false, messageKey = "clientSaveSuccess" }: SaveOptions = { + confirmed: false, + messageKey: "clientSaveSuccess", + }, + ) => { + if (!(await form.trigger())) { + return; + } + + if ( + !realmManagementClient?.publicClient && + realmManagementClient?.clientAuthenticatorType !== + clientAuthenticatorType && + !confirmed + ) { + toggleChangeAuthenticatorOpen(); + return; + } + + const values = convertFormValuesToObject(form.getValues()); + + const submittedClient = + convertFormValuesToObject(values); + + if (submittedClient.attributes?.["acr.loa.map"]) { + submittedClient.attributes["acr.loa.map"] = JSON.stringify( + Object.fromEntries( + (submittedClient.attributes["acr.loa.map"] as KeyValueType[]) + .filter(({ key }) => key !== "") + .map(({ key, value }) => [key, value]), + ), + ); + } + + try { + const newClient: ClientRepresentation = { + ...realmManagementClient, + ...submittedClient, + }; + + newClient.clientId = newClient.clientId?.trim(); + + await adminClient.clients.update( + { id: realmManagementClient!.clientId! }, + newClient, + ); + setupForm(newClient); + setRealmManagementClient(newClient); + addAlert(t(messageKey), AlertVariant.success); + } catch (error) { + addError("clientSaveError", error); + } + }; + + return ( + realmManagementClient && ( + <> + save({ confirmed: true })} + > + <> + {t("changeAuthenticatorConfirm", { + clientAuthenticatorType: clientAuthenticatorType, + })} + + + + + + + {t("settings")}} + {...permissionsSettingsTab} + > + + + {t("resources")}} + {...permissionsResourcesTab} + > + + + {t("scopes")}} + {...permissionsScopesTab} + > + + + {t("policies")}} + {...permissionsPoliciesTab} + > + + + {t("permissions")}} + {...permissionsPermissionsTab} + > + + + {hasViewUsers && ( + {t("evaluate")}} + {...permissionsEvaluateTab} + > + + + )} + {hasAccess("manage-authorization") && ( + {t("export")}} + {...permissionsExportTab} + > + + + )} + + + + + ) + ); +} diff --git a/js/apps/admin-ui/src/permissions/PermissionsSettings.tsx b/js/apps/admin-ui/src/permissions/PermissionsSettings.tsx new file mode 100644 index 000000000000..04d82fd18fc9 --- /dev/null +++ b/js/apps/admin-ui/src/permissions/PermissionsSettings.tsx @@ -0,0 +1,161 @@ +import type ResourceServerRepresentation from "@keycloak/keycloak-admin-client/lib/defs/resourceServerRepresentation"; +import { HelpItem, useAlerts, useFetch } from "@keycloak/keycloak-ui-shared"; +import { + AlertVariant, + Button, + Divider, + FormGroup, + PageSection, + Radio, +} from "@patternfly/react-core"; +import { useState } from "react"; +import { Controller, FormProvider, useForm } from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import { KeycloakSpinner } from "@keycloak/keycloak-ui-shared"; +import { ImportDialog } from "../clients/authorization/ImportDialog"; +import { useAdminClient } from "../admin-client"; +import useToggle from "../utils/useToggle"; +import { useAccess } from "../context/access/Access"; +import { FormAccess } from "../components/form/FormAccess"; +import { DecisionStrategySelect } from "../clients/authorization/DecisionStrategySelect"; +import { DefaultSwitchControl } from "../components/SwitchControl"; +import { FixedButtonsGroup } from "../components/form/FixedButtonGroup"; + +const POLICY_ENFORCEMENT_MODES = [ + "ENFORCING", + "PERMISSIVE", + "DISABLED", +] as const; + +export type FormFields = Omit< + ResourceServerRepresentation, + "scopes" | "resources" +>; + +export const PermissionsSettings = ({ clientId }: { clientId: string }) => { + const { adminClient } = useAdminClient(); + + const { t } = useTranslation(); + const [resource, setResource] = useState(); + const [importDialog, toggleImportDialog] = useToggle(); + + const form = useForm({}); + const { control, reset, handleSubmit } = form; + + const { addAlert, addError } = useAlerts(); + const { hasAccess } = useAccess(); + + const isDisabled = !hasAccess("manage-authorization"); + + useFetch( + () => adminClient.clients.getResourceServer({ id: clientId }), + (resource) => { + setResource(resource); + reset(resource); + }, + [], + ); + + const importResource = async (value: ResourceServerRepresentation) => { + try { + await adminClient.clients.importResource({ id: clientId }, value); + addAlert(t("importResourceSuccess"), AlertVariant.success); + reset({ ...value }); + } catch (error) { + addError("importResourceError", error); + } + }; + + const onSubmit = async (resource: ResourceServerRepresentation) => { + try { + await adminClient.clients.updateResourceServer( + { id: clientId }, + resource, + ); + addAlert(t("updateResourceSuccess"), AlertVariant.success); + } catch (error) { + addError("resourceSaveError", error); + } + }; + + if (!resource) { + return ; + } + + return ( + + {importDialog && ( + + )} + + + } + > + + + + + } + fieldId="policyEnforcementMode" + hasNoPaddingTop + > + ( + <> + {POLICY_ENFORCEMENT_MODES.map((mode) => ( + field.onChange(mode)} + label={t(`policyEnforcementModes.${mode}`)} + className="pf-v5-u-mb-md" + /> + ))} + + )} + /> + + + + + + reset(resource)} + isSubmit + /> + + + ); +}; diff --git a/js/apps/admin-ui/src/permissions/routes.ts b/js/apps/admin-ui/src/permissions/routes.ts new file mode 100644 index 000000000000..4c713caff379 --- /dev/null +++ b/js/apps/admin-ui/src/permissions/routes.ts @@ -0,0 +1,7 @@ +import type { AppRouteObject } from "../routes"; +import { PermissionsRoute } from "./routes/Permissions"; +import { PermissionsTabsRoute } from "./routes/PermissionsTabs"; + +const routes: AppRouteObject[] = [PermissionsRoute, PermissionsTabsRoute]; + +export default routes; diff --git a/js/apps/admin-ui/src/permissions/routes/Permissions.tsx b/js/apps/admin-ui/src/permissions/routes/Permissions.tsx new file mode 100644 index 000000000000..67af3b624fff --- /dev/null +++ b/js/apps/admin-ui/src/permissions/routes/Permissions.tsx @@ -0,0 +1,21 @@ +import { lazy } from "react"; +import type { Path } from "react-router-dom"; +import { generateEncodedPath } from "../../utils/generateEncodedPath"; +import type { AppRouteObject } from "../../routes"; + +export type PermissionsParams = { realm: string }; + +const PermissionsSection = lazy(() => import("../PermissionsSection")); + +export const PermissionsRoute: AppRouteObject = { + path: "/:realm/permissions", + element: , + breadcrumb: (t) => t("titlePermissions"), + handle: { + access: ["view-realm", "view-clients", "view-users"], + }, +}; + +export const toPermissions = (params: PermissionsParams): Partial => ({ + pathname: generateEncodedPath(PermissionsRoute.path, params), +}); diff --git a/js/apps/admin-ui/src/permissions/routes/PermissionsTabs.tsx b/js/apps/admin-ui/src/permissions/routes/PermissionsTabs.tsx new file mode 100644 index 000000000000..fd90bd641640 --- /dev/null +++ b/js/apps/admin-ui/src/permissions/routes/PermissionsTabs.tsx @@ -0,0 +1,35 @@ +import { lazy } from "react"; +import type { Path } from "react-router-dom"; +import { generateEncodedPath } from "../../utils/generateEncodedPath"; +import type { AppRouteObject } from "../../routes"; + +export type PermissionsTabs = + | "settings" + | "resources" + | "scopes" + | "policies" + | "permissions" + | "evaluate" + | "export"; + +export type PermissionsTabsParams = { + realm: string; + tab: PermissionsTabs; +}; + +const PermissionsSection = lazy(() => import("../PermissionsSection")); + +export const PermissionsTabsRoute: AppRouteObject = { + path: "/:realm/permissions/:tab", + element: , + handle: { + access: (accessChecker) => + accessChecker.hasAny("view-realm", "view-clients", "view-users"), + }, +}; + +export const toPermissionsTabs = ( + params: PermissionsTabsParams, +): Partial => ({ + pathname: generateEncodedPath(PermissionsTabsRoute.path, params), +}); diff --git a/js/apps/admin-ui/src/routes.tsx b/js/apps/admin-ui/src/routes.tsx index 74d4d6f71251..bf8763cd793a 100644 --- a/js/apps/admin-ui/src/routes.tsx +++ b/js/apps/admin-ui/src/routes.tsx @@ -19,6 +19,7 @@ import realmRoutes from "./realm/routes"; import sessionRoutes from "./sessions/routes"; import userFederationRoutes from "./user-federation/routes"; import userRoutes from "./user/routes"; +import permissionsRoute from "./permissions/routes"; export type AppRouteObjectHandle = { access: AccessType | AccessType[]; @@ -50,6 +51,7 @@ export const routes: AppRouteObject[] = [ ...realmSettingRoutes, ...sessionRoutes, ...userFederationRoutes, + ...permissionsRoute, ...userRoutes, ...groupsRoutes, ...dashboardRoutes,