Clients(authorization -> evaluate): Adds expandable results table (#2004)

This commit is contained in:
Jenny 2022-02-17 11:03:18 -05:00 committed by GitHub
parent e8e64798ea
commit fa0e162c0b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 455 additions and 7 deletions

View file

@ -12,6 +12,11 @@ import {
Switch, Switch,
ExpandableSection, ExpandableSection,
TextInput, TextInput,
ButtonVariant,
InputGroup,
Toolbar,
ToolbarGroup,
ToolbarItem,
} from "@patternfly/react-core"; } from "@patternfly/react-core";
import { Controller, useFormContext } from "react-hook-form"; import { Controller, useFormContext } from "react-hook-form";
@ -25,10 +30,16 @@ import type ResourceEvaluation from "@keycloak/keycloak-admin-client/lib/defs/re
import { useRealm } from "../../context/realm-context/RealmContext"; import { useRealm } from "../../context/realm-context/RealmContext";
import { AttributeInput } from "../../components/attribute-input/AttributeInput"; import { AttributeInput } from "../../components/attribute-input/AttributeInput";
import { defaultContextAttributes } from "../utils"; import { defaultContextAttributes } from "../utils";
import type EvaluationResultRepresentation from "@keycloak/keycloak-admin-client/lib/defs/evaluationResultRepresentation";
import type ResourceRepresentation from "@keycloak/keycloak-admin-client/lib/defs/resourceRepresentation"; import type ResourceRepresentation from "@keycloak/keycloak-admin-client/lib/defs/resourceRepresentation";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import type ScopeRepresentation from "@keycloak/keycloak-admin-client/lib/defs/scopeRepresentation"; import type ScopeRepresentation from "@keycloak/keycloak-admin-client/lib/defs/scopeRepresentation";
import type { KeyValueType } from "../../components/attribute-form/attribute-convert"; import type { KeyValueType } from "../../components/attribute-form/attribute-convert";
import { TableComposable, Th, Thead, Tr } from "@patternfly/react-table";
import "./auth-evaluate.css";
import { AuthorizationEvaluateResource } from "./AuthorizationEvaluateResource";
import { SearchIcon } from "@patternfly/react-icons";
import { ListEmptyState } from "../../components/list-empty-state/ListEmptyState";
interface EvaluateFormInputs interface EvaluateFormInputs
extends Omit<ResourceEvaluation, "context" | "resources"> { extends Omit<ResourceEvaluation, "context" | "resources"> {
@ -68,12 +79,14 @@ export type AttributeForm = Omit<
resources?: KeyValueType[]; resources?: KeyValueType[];
}; };
type Props = ClientSettingsProps & EvaluationResultRepresentation;
export const AuthorizationEvaluate = ({ export const AuthorizationEvaluate = ({
clients, clients,
clientRoles, clientRoles,
clientName, clientName,
users, users,
}: ClientSettingsProps) => { }: Props) => {
const form = useFormContext<EvaluateFormInputs>(); const form = useFormContext<EvaluateFormInputs>();
const { control, reset, trigger } = form; const { control, reset, trigger } = form;
const { t } = useTranslation("clients"); const { t } = useTranslation("clients");
@ -92,6 +105,28 @@ export const AuthorizationEvaluate = ({
const [scopes, setScopes] = useState<ScopeRepresentation[]>([]); const [scopes, setScopes] = useState<ScopeRepresentation[]>([]);
const [selectedClient, setSelectedClient] = useState<ClientRepresentation>(); const [selectedClient, setSelectedClient] = useState<ClientRepresentation>();
const [selectedUser, setSelectedUser] = useState<UserRepresentation>(); const [selectedUser, setSelectedUser] = useState<UserRepresentation>();
const [evaluateResults, setEvaluateResults] = useState<
EvaluationResultRepresentation[]
>([]);
const [showEvaluateResults, setShowEvaluateResults] = useState(false);
const [searchVal, setSearchVal] = useState("");
const [filteredResources, setFilteredResources] = useState<
EvaluationResultRepresentation[]
>([]);
const [filterDropdownOpen, setFilterDropdownOpen] = useState(false);
const [key, setKey] = useState(0);
const refresh = () => {
setKey(new Date().getTime());
};
const FilterType = {
allResults: t("allResults"),
resultPermit: t("resultPermit"),
resultDeny: t("resultDeny"),
};
const [filterType, setFilterType] = useState(FilterType.allResults);
useFetch( useFetch(
async () => async () =>
@ -107,7 +142,7 @@ export const AuthorizationEvaluate = ({
setResources(resources); setResources(resources);
setScopes(scopes); setScopes(scopes);
}, },
[] [key, filterType]
); );
const evaluate = async () => { const evaluate = async () => {
@ -131,13 +166,175 @@ export const AuthorizationEvaluate = ({
}, },
}; };
return adminClient.clients.evaluateResource( const evaluation = await adminClient.clients.evaluateResource(
{ id: clientId!, realm: realm.realm }, { id: clientId!, realm: realm.realm },
resEval resEval
); );
setEvaluateResults(evaluation.results);
setShowEvaluateResults(true);
return evaluateResults;
}; };
return ( const onSearch = () => {
if (searchVal !== "") {
setSearchVal(searchVal);
const filtered = evaluateResults.filter((resource) =>
resource.resource?.name?.includes(searchVal)
);
setFilteredResources(filtered);
} else {
setSearchVal("");
setFilteredResources(evaluateResults);
}
};
const handleKeyDown = (e: any) => {
if (e.key === "Enter") {
onSearch();
}
};
const handleInputChange = (value: string) => {
setSearchVal(value);
};
const noEvaluatedData = evaluateResults.length === 0;
const noFilteredData = filteredResources.length === 0;
const options = [
<SelectOption
key={1}
data-testid="all-results-option"
value={FilterType.allResults}
isPlaceholder
/>,
<SelectOption
data-testid="result-permit-option"
key={2}
value={FilterType.resultPermit}
/>,
<SelectOption
data-testid="result-deny-option"
key={3}
value={FilterType.resultDeny}
/>,
];
return showEvaluateResults ? (
<PageSection>
<Toolbar>
<ToolbarGroup className="providers-toolbar">
<ToolbarItem>
<InputGroup>
<TextInput
name={"inputGroupName"}
id={"inputGroupName"}
type="search"
aria-label={t("common:search")}
placeholder={t("common:search")}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
/>
<Button
variant={ButtonVariant.control}
aria-label={t("common:search")}
onClick={() => onSearch()}
>
<SearchIcon />
</Button>
</InputGroup>
</ToolbarItem>
<ToolbarItem>
<Select
width={300}
data-testid="filter-type-select"
isOpen={filterDropdownOpen}
className="kc-filter-type-select"
variant={SelectVariant.single}
onToggle={() => setFilterDropdownOpen(!filterDropdownOpen)}
onSelect={(_, value) => {
if (value === FilterType.allResults) {
setFilterType(FilterType.allResults);
} else if (value === FilterType.resultPermit) {
const filterPermit = evaluateResults.filter(
(resource) => resource.status === "PERMIT"
);
setFilteredResources(filterPermit);
setFilterType(FilterType.resultPermit);
refresh();
} else if (value === FilterType.resultDeny) {
const filterDeny = evaluateResults.filter(
(resource) => resource.status === "DENY"
);
setFilterType(FilterType.resultDeny);
setFilteredResources(filterDeny);
refresh();
}
setFilterDropdownOpen(false);
}}
selections={filterType}
>
{options}
</Select>
</ToolbarItem>
</ToolbarGroup>
</Toolbar>
{!noEvaluatedData && !noFilteredData && (
<TableComposable aria-label={t("evaluationResults")}>
<Thead>
<Tr>
<Th />
<Th>{t("resource")}</Th>
<Th>{t("overallResults")}</Th>
<Th>{t("scopes")}</Th>
<Th />
</Tr>
</Thead>
{(filterType == FilterType.allResults
? evaluateResults
: filteredResources
).map((resource, rowIndex) => (
<AuthorizationEvaluateResource
key={rowIndex}
rowIndex={rowIndex}
resource={resource}
evaluateResults={evaluateResults}
/>
))}
</TableComposable>
)}
{noEvaluatedData ||
(noFilteredData && (
<ListEmptyState
isSearchVariant
message={t("common:noSearchResults")}
instructions={t("common:noSearchResultsInstructions")}
/>
))}
<ActionGroup className="kc-evaluated-options">
<Button
data-testid="authorization-eval"
id="back-btn"
onClick={() => setShowEvaluateResults(false)}
>
{t("common:back")}
</Button>
<Button
data-testid="authorization-reevaluate"
id="reevaluate-btn"
variant="secondary"
onClick={() => evaluate()}
>
{t("clients:reevaluate")}
</Button>
<Button data-testid="authorization-revert" variant="secondary">
{t("showAuthData")}
</Button>
</ActionGroup>
</PageSection>
) : (
<PageSection> <PageSection>
<FormPanel <FormPanel
className="kc-identity-information" className="kc-identity-information"
@ -332,7 +529,7 @@ export const AuthorizationEvaluate = ({
/> />
} }
helperTextInvalid={t("common:required")} helperTextInvalid={t("common:required")}
fieldId={name!} fieldId="resourcesAndAuthScopes"
> >
<AttributeInput <AttributeInput
selectableValues={resources.map<AttributeType>((item) => ({ selectableValues={resources.map<AttributeType>((item) => ({
@ -428,7 +625,7 @@ export const AuthorizationEvaluate = ({
/> />
} }
helperTextInvalid={t("common:required")} helperTextInvalid={t("common:required")}
fieldId={name!} fieldId="contextualAttributes"
> >
<AttributeInput <AttributeInput
selectableValues={defaultContextAttributes} selectableValues={defaultContextAttributes}
@ -439,7 +636,12 @@ export const AuthorizationEvaluate = ({
</ExpandableSection> </ExpandableSection>
</FormAccess> </FormAccess>
<ActionGroup> <ActionGroup>
<Button data-testid="authorization-eval" onClick={() => evaluate()}> <Button
data-testid="authorization-eval"
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
isDisabled={form.getValues().resources?.every((e) => e.key === "")}
onClick={() => evaluate()}
>
{t("evaluate")} {t("evaluate")}
</Button> </Button>
<Button <Button

View file

@ -0,0 +1,93 @@
import React, { useState } from "react";
import {
ExpandableRowContent,
TableComposable,
Tbody,
Td,
Th,
Thead,
Tr,
} from "@patternfly/react-table";
import { DescriptionList } from "@patternfly/react-core/dist/esm/components";
import { useTranslation } from "react-i18next";
import { AuthorizationEvaluateResourcePolicies } from "./AuthorizationEvaluateResourcePolicies";
import type EvaluationResultRepresentation from "@keycloak/keycloak-admin-client/lib/defs/evaluationResultRepresentation";
import type PolicyResultRepresentation from "@keycloak/keycloak-admin-client/lib/defs/policyResultRepresentation";
type Props = {
rowIndex: number;
resource: EvaluationResultRepresentation;
evaluateResults: any;
};
export const AuthorizationEvaluateResource = ({
rowIndex,
resource,
evaluateResults,
}: Props) => {
const [expanded, setExpanded] = useState<boolean>(false);
const { t } = useTranslation("clients");
return (
<Tbody isExpanded={expanded}>
<Tr>
<Td
expand={{
rowIndex,
isExpanded: expanded,
onToggle: () => setExpanded((prev) => !prev),
}}
/>
<Td data-testid={`name-column-${resource.resource}`}>
{resource.resource?.name}
</Td>
<Td id={resource.status?.toLowerCase()}>
{t(`${resource.status?.toLowerCase()}`)}
</Td>
<Td>
{resource.allowedScopes?.length
? resource.allowedScopes.map((item) => item.name)
: "-"}
</Td>
</Tr>
<Tr key={`child-${resource.resource}`} isExpanded={expanded}>
<Td />
<Td colSpan={5}>
<ExpandableRowContent>
{expanded && (
<DescriptionList
isHorizontal
className="keycloak_resource_details"
>
<TableComposable aria-label={t("evaluationResults")}>
<Thead>
<Tr>
<Th />
<Th>{t("permission")}</Th>
<Th>{t("results")}</Th>
<Th>{t("decisionStrategy")}</Th>
<Th>{t("grantedScopes")}</Th>
<Th>{t("deniedScopes")}</Th>
<Th />
</Tr>
</Thead>
{Object.values(evaluateResults[rowIndex].policies).map(
(outerPolicy, idx) => (
<AuthorizationEvaluateResourcePolicies
key={idx}
idx={idx}
rowIndex={rowIndex}
outerPolicy={outerPolicy as PolicyResultRepresentation}
resource={resource}
/>
)
)}
</TableComposable>
</DescriptionList>
)}
</ExpandableRowContent>
</Td>
</Tr>
</Tbody>
);
};

View file

@ -0,0 +1,114 @@
import React, { useState } from "react";
import {
DescriptionList,
TextContent,
TextList,
TextListItem,
capitalize,
} from "@patternfly/react-core";
import { Tbody, Tr, Td, ExpandableRowContent } from "@patternfly/react-table";
import { useTranslation } from "react-i18next";
import type PolicyResultRepresentation from "@keycloak/keycloak-admin-client/lib/defs/policyResultRepresentation";
import type EvaluationResultRepresentation from "@keycloak/keycloak-admin-client/lib/defs/evaluationResultRepresentation";
import { DecisionEffect } from "@keycloak/keycloak-admin-client/lib/defs/policyRepresentation";
import { Link, useParams } from "react-router-dom";
import { toPermissionDetails } from "../routes/PermissionDetails";
import { toPolicyDetails } from "../routes/PolicyDetails";
import { useRealm } from "../../context/realm-context/RealmContext";
import type { ClientParams } from "../routes/Client";
type Props = {
idx: number;
rowIndex: number;
outerPolicy: PolicyResultRepresentation;
resource: EvaluationResultRepresentation;
};
export const AuthorizationEvaluateResourcePolicies = ({
idx,
rowIndex,
outerPolicy,
resource,
}: Props) => {
const [expanded, setExpanded] = useState<boolean>(false);
const { t } = useTranslation("clients");
const { realm } = useRealm();
const { clientId } = useParams<ClientParams>();
return (
<Tbody key={idx} isExpanded={expanded}>
<Tr>
<Td
expand={{
rowIndex,
isExpanded: expanded,
onToggle: () => setExpanded((prev) => !prev),
}}
/>
<Td data-testid={`name-column-${resource.resource}`}>
<Link
to={toPermissionDetails({
realm,
id: clientId,
permissionType: outerPolicy.policy?.type!,
permissionId: outerPolicy.policy?.id!,
})}
>
{outerPolicy.policy?.name}
</Link>
</Td>
<Td id={outerPolicy.status?.toLowerCase()}>
{t(outerPolicy.status?.toLowerCase() as string)}
</Td>
<Td>{t(`${outerPolicy.policy?.decisionStrategy?.toLowerCase()}`)}</Td>
<Td>
{outerPolicy.status === DecisionEffect.Permit
? resource.policies?.[rowIndex].scopes?.join(", ")
: "-"}
</Td>
<Td>
{outerPolicy.status === DecisionEffect.Deny &&
resource.policies?.[rowIndex]?.scopes?.length
? resource.policies[rowIndex].scopes?.join(", ")
: "-"}
</Td>
</Tr>
<Tr key={`child-${resource.resource}`} isExpanded={expanded}>
<Td />
<Td colSpan={5}>
{expanded && (
<ExpandableRowContent>
<DescriptionList
isHorizontal
className="keycloak_resource_details"
>
<TextContent>
<TextList>
{outerPolicy.associatedPolicies?.map((item) => (
<TextListItem key="policyDetails">
<Link
to={toPolicyDetails({
realm,
id: clientId,
policyType: item.policy?.type!,
policyId: item.policy?.id!,
})}
>
{item.policy?.name}
</Link>
{t("votedToStatus", {
status: capitalize(item.status as string),
})}
</TextListItem>
))}
</TextList>
</TextContent>
</DescriptionList>
</ExpandableRowContent>
)}
</Td>
</Tr>
</Tbody>
);
};

View file

@ -0,0 +1,23 @@
td#permit {
color: var(--pf-global--success-color--100);
font-weight: bold;;
}
td#deny {
color: var(--pf-global--danger-color--100);
font-weight: bold;
}
.kc-evaluated-options {
padding-top: 300px
}
button#back-btn {
margin-right: var(--pf-global--spacer--md);
}
button#reevaluate-btn {
margin-right: var(--pf-global--spacer--md);
}

View file

@ -44,6 +44,22 @@ export default {
selectAUser: "Select a user", selectAUser: "Select a user",
client: "Client", client: "Client",
evaluate: "Evaluate", evaluate: "Evaluate",
reevaluate: "Re-evaluate",
showAuthData: "Show authorization data",
results: "Results",
allResults: "All results",
resultPermit: "Result-Permit",
resultDeny: "Result-Deny",
permit: "Permit",
deny: "Deny",
unanimous: "Unanimous",
affirmative: "Affirmative",
consensus: "Consensus",
votedToStatus: " voted to {{status}}",
overallResults: "Overall Results",
grantedScopes: "Granted scopes",
deniedScopes: "Denied scopes",
permission: "Permission",
lastEvaluation: "Last Evaluation", lastEvaluation: "Last Evaluation",
resourcesAndAuthScopes: "Resources and Authentication Scopes", resourcesAndAuthScopes: "Resources and Authentication Scopes",
authScopes: "Authorization scopes", authScopes: "Authorization scopes",