Clients(authorization -> evaluate): Adds expandable results table (#2004)
This commit is contained in:
parent
e8e64798ea
commit
fa0e162c0b
5 changed files with 455 additions and 7 deletions
|
@ -12,6 +12,11 @@ import {
|
|||
Switch,
|
||||
ExpandableSection,
|
||||
TextInput,
|
||||
ButtonVariant,
|
||||
InputGroup,
|
||||
Toolbar,
|
||||
ToolbarGroup,
|
||||
ToolbarItem,
|
||||
} from "@patternfly/react-core";
|
||||
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 { AttributeInput } from "../../components/attribute-input/AttributeInput";
|
||||
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 { useParams } from "react-router-dom";
|
||||
import type ScopeRepresentation from "@keycloak/keycloak-admin-client/lib/defs/scopeRepresentation";
|
||||
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
|
||||
extends Omit<ResourceEvaluation, "context" | "resources"> {
|
||||
|
@ -68,12 +79,14 @@ export type AttributeForm = Omit<
|
|||
resources?: KeyValueType[];
|
||||
};
|
||||
|
||||
type Props = ClientSettingsProps & EvaluationResultRepresentation;
|
||||
|
||||
export const AuthorizationEvaluate = ({
|
||||
clients,
|
||||
clientRoles,
|
||||
clientName,
|
||||
users,
|
||||
}: ClientSettingsProps) => {
|
||||
}: Props) => {
|
||||
const form = useFormContext<EvaluateFormInputs>();
|
||||
const { control, reset, trigger } = form;
|
||||
const { t } = useTranslation("clients");
|
||||
|
@ -92,6 +105,28 @@ export const AuthorizationEvaluate = ({
|
|||
const [scopes, setScopes] = useState<ScopeRepresentation[]>([]);
|
||||
const [selectedClient, setSelectedClient] = useState<ClientRepresentation>();
|
||||
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(
|
||||
async () =>
|
||||
|
@ -107,7 +142,7 @@ export const AuthorizationEvaluate = ({
|
|||
setResources(resources);
|
||||
setScopes(scopes);
|
||||
},
|
||||
[]
|
||||
[key, filterType]
|
||||
);
|
||||
|
||||
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 },
|
||||
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>
|
||||
<FormPanel
|
||||
className="kc-identity-information"
|
||||
|
@ -332,7 +529,7 @@ export const AuthorizationEvaluate = ({
|
|||
/>
|
||||
}
|
||||
helperTextInvalid={t("common:required")}
|
||||
fieldId={name!}
|
||||
fieldId="resourcesAndAuthScopes"
|
||||
>
|
||||
<AttributeInput
|
||||
selectableValues={resources.map<AttributeType>((item) => ({
|
||||
|
@ -428,7 +625,7 @@ export const AuthorizationEvaluate = ({
|
|||
/>
|
||||
}
|
||||
helperTextInvalid={t("common:required")}
|
||||
fieldId={name!}
|
||||
fieldId="contextualAttributes"
|
||||
>
|
||||
<AttributeInput
|
||||
selectableValues={defaultContextAttributes}
|
||||
|
@ -439,7 +636,12 @@ export const AuthorizationEvaluate = ({
|
|||
</ExpandableSection>
|
||||
</FormAccess>
|
||||
<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")}
|
||||
</Button>
|
||||
<Button
|
||||
|
|
93
src/clients/authorization/AuthorizationEvaluateResource.tsx
Normal file
93
src/clients/authorization/AuthorizationEvaluateResource.tsx
Normal 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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
23
src/clients/authorization/auth-evaluate.css
Normal file
23
src/clients/authorization/auth-evaluate.css
Normal 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);
|
||||
}
|
||||
|
||||
|
|
@ -44,6 +44,22 @@ export default {
|
|||
selectAUser: "Select a user",
|
||||
client: "Client",
|
||||
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",
|
||||
resourcesAndAuthScopes: "Resources and Authentication Scopes",
|
||||
authScopes: "Authorization scopes",
|
||||
|
|
Loading…
Reference in a new issue