added permission search (#1890)

This commit is contained in:
Erik Jan de Wit 2022-01-24 15:17:32 +01:00 committed by GitHub
parent 92c73a7fcc
commit c4724b21c8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 186 additions and 106 deletions

View file

@ -30,12 +30,13 @@ import { useAlerts } from "../../components/alert/Alerts";
import { useAdminClient, useFetch } from "../../context/auth/AdminClient"; import { useAdminClient, useFetch } from "../../context/auth/AdminClient";
import useToggle from "../../utils/useToggle"; import useToggle from "../../utils/useToggle";
import { useRealm } from "../../context/realm-context/RealmContext"; import { useRealm } from "../../context/realm-context/RealmContext";
import { SearchDropdown } from "./SearchDropdown"; import { SearchDropdown, SearchForm } from "./SearchDropdown";
import { MoreLabel } from "./MoreLabel"; import { MoreLabel } from "./MoreLabel";
import { DetailDescription } from "./DetailDescription"; import { DetailDescription } from "./DetailDescription";
import { EmptyPermissionsState } from "./EmptyPermissionsState"; import { EmptyPermissionsState } from "./EmptyPermissionsState";
import { toNewPermission } from "../routes/NewPermission"; import { toNewPermission } from "../routes/NewPermission";
import { toPermissionDetails } from "../routes/PermissionDetails"; import { toPermissionDetails } from "../routes/PermissionDetails";
import { ListEmptyState } from "../../components/list-empty-state/ListEmptyState";
import "./permissions.css"; import "./permissions.css";
@ -64,6 +65,7 @@ export const AuthorizationPermissions = ({ clientId }: PermissionsProps) => {
const [disabledCreate, setDisabledCreate] = const [disabledCreate, setDisabledCreate] =
useState<{ resources: boolean; scopes: boolean }>(); useState<{ resources: boolean; scopes: boolean }>();
const [createOpen, toggleCreate] = useToggle(); const [createOpen, toggleCreate] = useToggle();
const [search, setSearch] = useState<SearchForm>({});
const [key, setKey] = useState(0); const [key, setKey] = useState(0);
const refresh = () => setKey(key + 1); const refresh = () => setKey(key + 1);
@ -90,6 +92,7 @@ export const AuthorizationPermissions = ({ clientId }: PermissionsProps) => {
first, first,
max, max,
id: clientId, id: clientId,
...search,
}); });
return await Promise.all( return await Promise.all(
@ -109,7 +112,7 @@ export const AuthorizationPermissions = ({ clientId }: PermissionsProps) => {
); );
}, },
setPermissions, setPermissions,
[key] [key, search]
); );
useFetch( useFetch(
@ -166,10 +169,12 @@ export const AuthorizationPermissions = ({ clientId }: PermissionsProps) => {
return <KeycloakSpinner />; return <KeycloakSpinner />;
} }
const noData = permissions.length === 0;
const searching = Object.keys(search).length !== 0;
return ( return (
<PageSection variant="light" className="pf-u-p-0"> <PageSection variant="light" className="pf-u-p-0">
<DeleteConfirm /> <DeleteConfirm />
{permissions.length > 0 && ( {(!noData || searching) && (
<PaginatingTableToolbar <PaginatingTableToolbar
count={permissions.length} count={permissions.length}
first={first} first={first}
@ -183,7 +188,7 @@ export const AuthorizationPermissions = ({ clientId }: PermissionsProps) => {
toolbarItem={ toolbarItem={
<> <>
<ToolbarItem> <ToolbarItem>
<SearchDropdown types={policyProviders} /> <SearchDropdown types={policyProviders} onSearch={setSearch} />
</ToolbarItem> </ToolbarItem>
<ToolbarItem> <ToolbarItem>
<Dropdown <Dropdown
@ -238,104 +243,113 @@ export const AuthorizationPermissions = ({ clientId }: PermissionsProps) => {
</> </>
} }
> >
<TableComposable aria-label={t("resources")} variant="compact"> {!noData && (
<Thead> <TableComposable aria-label={t("resources")} variant="compact">
<Tr> <Thead>
<Th />
<Th>{t("common:name")}</Th>
<Th>{t("common:type")}</Th>
<Th>{t("associatedPolicy")}</Th>
<Th>{t("common:description")}</Th>
<Th />
</Tr>
</Thead>
{permissions.map((permission, rowIndex) => (
<Tbody key={permission.id} isExpanded={permission.isExpanded}>
<Tr> <Tr>
<Td <Th />
expand={{ <Th>{t("common:name")}</Th>
rowIndex, <Th>{t("common:type")}</Th>
isExpanded: permission.isExpanded, <Th>{t("associatedPolicy")}</Th>
onToggle: (_, rowIndex) => { <Th>{t("common:description")}</Th>
const rows = permissions.map((p, index) => <Th />
index === rowIndex </Tr>
? { ...p, isExpanded: !p.isExpanded } </Thead>
: p {permissions.map((permission, rowIndex) => (
); <Tbody key={permission.id} isExpanded={permission.isExpanded}>
setPermissions(rows); <Tr>
}, <Td
}} expand={{
/> rowIndex,
<Td data-testid={`name-column-${permission.name}`}> isExpanded: permission.isExpanded,
<Link onToggle: (_, rowIndex) => {
to={toPermissionDetails({ const rows = permissions.map((p, index) =>
realm, index === rowIndex
id: clientId, ? { ...p, isExpanded: !p.isExpanded }
permissionType: permission.type!, : p
permissionId: permission.id!, );
})} setPermissions(rows);
>
{permission.name}
</Link>
</Td>
<Td>
{
policyProviders?.find((p) => p.type === permission.type)
?.name
}
</Td>
<Td>
<AssociatedPoliciesRenderer row={permission} />
</Td>
<Td>{permission.description}</Td>
<Td
actions={{
items: [
{
title: t("common:delete"),
onClick: async () => {
setSelectedPermission(permission);
toggleDeleteDialog();
},
}, },
], }}
}} />
></Td> <Td data-testid={`name-column-${permission.name}`}>
</Tr> <Link
<Tr to={toPermissionDetails({
key={`child-${permission.id}`} realm,
isExpanded={permission.isExpanded} id: clientId,
> permissionType: permission.type!,
<Td /> permissionId: permission.id!,
<Td colSpan={5}> })}
<ExpandableRowContent> >
{permission.isExpanded && ( {permission.name}
<DescriptionList </Link>
isHorizontal </Td>
className="keycloak_resource_details" <Td>
> {
<DetailDescription policyProviders?.find((p) => p.type === permission.type)
name="associatedPolicy" ?.name
array={permission.associatedPolicies} }
convert={(p) => p.name!} </Td>
/> <Td>
</DescriptionList> <AssociatedPoliciesRenderer row={permission} />
)} </Td>
</ExpandableRowContent> <Td>{permission.description}</Td>
</Td> <Td
</Tr> actions={{
</Tbody> items: [
))} {
</TableComposable> title: t("common:delete"),
onClick: async () => {
setSelectedPermission(permission);
toggleDeleteDialog();
},
},
],
}}
></Td>
</Tr>
<Tr
key={`child-${permission.id}`}
isExpanded={permission.isExpanded}
>
<Td />
<Td colSpan={5}>
<ExpandableRowContent>
{permission.isExpanded && (
<DescriptionList
isHorizontal
className="keycloak_resource_details"
>
<DetailDescription
name="associatedPolicy"
array={permission.associatedPolicies}
convert={(p) => p.name!}
/>
</DescriptionList>
)}
</ExpandableRowContent>
</Td>
</Tr>
</Tbody>
))}
</TableComposable>
)}
</PaginatingTableToolbar> </PaginatingTableToolbar>
)} )}
{permissions.length === 0 && ( {noData && !searching && (
<EmptyPermissionsState <EmptyPermissionsState
clientId={clientId} clientId={clientId}
isResourceEnabled={disabledCreate?.resources} isResourceEnabled={disabledCreate?.resources}
isScopeEnabled={disabledCreate?.scopes} isScopeEnabled={disabledCreate?.scopes}
/> />
)} )}
{noData && searching && (
<ListEmptyState
isSearchVariant
message={t("common:noSearchResults")}
instructions={t("common:noSearchResultsInstructions")}
/>
)}
</PageSection> </PageSection>
); );
}; };

View file

@ -2,6 +2,8 @@ import React from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { import {
ActionGroup,
Button,
Dropdown, Dropdown,
DropdownToggle, DropdownToggle,
Form, Form,
@ -17,17 +19,50 @@ import useToggle from "../../utils/useToggle";
import "./search-dropdown.css"; import "./search-dropdown.css";
type SearchDropdownProps = { export type SearchForm = {
types?: PolicyProviderRepresentation[]; name?: string;
resource?: string;
scope?: string;
type?: string;
}; };
export const SearchDropdown = ({ types }: SearchDropdownProps) => { type SearchDropdownProps = {
types?: PolicyProviderRepresentation[];
onSearch: (form: SearchForm) => void;
};
export const SearchDropdown = ({ types, onSearch }: SearchDropdownProps) => {
const { t } = useTranslation("clients"); const { t } = useTranslation("clients");
const { register, control } = useForm(); const {
register,
control,
formState: { isDirty },
handleSubmit,
} = useForm<SearchForm>({ mode: "onChange" });
const [open, toggle] = useToggle(); const [open, toggle] = useToggle();
const [typeOpen, toggleType] = useToggle(); const [typeOpen, toggleType] = useToggle();
const submit = (form: SearchForm) => {
toggle();
onSearch(form);
};
const typeOptions = (value: string) => [
<SelectOption key="empty" value="">
{t("allTypes")}
</SelectOption>,
...(types || []).map((type) => (
<SelectOption
selected={type.type === value}
key={type.type}
value={type.type}
>
{type.name}
</SelectOption>
)),
];
return ( return (
<Dropdown <Dropdown
data-testid="searchdropdown_dorpdown" data-testid="searchdropdown_dorpdown"
@ -45,6 +80,7 @@ export const SearchDropdown = ({ types }: SearchDropdownProps) => {
<Form <Form
isHorizontal isHorizontal
className="keycloak__client_authentication__searchdropdown_form" className="keycloak__client_authentication__searchdropdown_form"
onSubmit={handleSubmit(submit)}
> >
<FormGroup label={t("common:name")} fieldId="name"> <FormGroup label={t("common:name")} fieldId="name">
<TextInput <TextInput
@ -55,6 +91,24 @@ export const SearchDropdown = ({ types }: SearchDropdownProps) => {
data-testid="searchdropdown_name" data-testid="searchdropdown_name"
/> />
</FormGroup> </FormGroup>
<FormGroup label={t("resource")} fieldId="resource">
<TextInput
ref={register}
type="text"
id="resource"
name="resource"
data-testid="searchdropdown_resource"
/>
</FormGroup>
<FormGroup label={t("scope")} fieldId="scope">
<TextInput
ref={register}
type="text"
id="scope"
name="scope"
data-testid="searchdropdown_scope"
/>
</FormGroup>
<FormGroup label={t("common:type")} fieldId="type"> <FormGroup label={t("common:type")} fieldId="type">
<Controller <Controller
name="type" name="type"
@ -69,24 +123,33 @@ export const SearchDropdown = ({ types }: SearchDropdownProps) => {
onChange(value); onChange(value);
toggleType(); toggleType();
}} }}
selections={value.name} selections={value || t("allTypes")}
variant={SelectVariant.single} variant={SelectVariant.single}
aria-label={t("common:type")} aria-label={t("common:type")}
isOpen={typeOpen} isOpen={typeOpen}
> >
{types?.map((type) => ( {typeOptions(value)}
<SelectOption
selected={type.type === value.type}
key={type.type}
value={type}
>
{type.name}
</SelectOption>
))}
</Select> </Select>
)} )}
/> />
</FormGroup> </FormGroup>
<ActionGroup>
<Button
variant="primary"
type="submit"
data-testid="search-btn"
isDisabled={!isDirty}
>
{t("common:search")}
</Button>
<Button
variant="link"
data-testid="revert-btn"
onClick={() => onSearch({})}
>
{t("common:clear")}
</Button>
</ActionGroup>
</Form> </Form>
</Dropdown> </Dropdown>
); );

View file

@ -126,6 +126,9 @@ export default {
associatedPermissions: "Associated permission", associatedPermissions: "Associated permission",
allowRemoteResourceManagement: "Remote resource management", allowRemoteResourceManagement: "Remote resource management",
resources: "Resources", resources: "Resources",
resource: "Resource",
allTypes: "All types",
scope: "Scope",
owner: "Owner", owner: "Owner",
uris: "URIs", uris: "URIs",
scopes: "Scopes", scopes: "Scopes",