Added initial "Authorisation" tabs Settings and Resources (#1524)

This commit is contained in:
Erik Jan de Wit 2021-11-17 09:27:56 +01:00 committed by GitHub
parent ede8db53a0
commit 15baa43cfb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 559 additions and 3 deletions

View file

@ -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<ClientForm>({ shouldUnregister: false });
const { clientId } = useParams<ClientParams>();
@ -446,8 +449,8 @@ export default function ClientDetails() {
title={<TabTitleText>{t("clientScopes")}</TabTitleText>}
>
<Tabs
activeKey={activeTab2}
onSelect={(_, key) => setActiveTab2(key as number)}
activeKey={clientScopeSubTab}
onSelect={(_, key) => setClientScopeSubTab(key as number)}
>
<Tab
id="setup"
@ -472,6 +475,33 @@ export default function ClientDetails() {
</Tabs>
</Tab>
)}
{client!.serviceAccountsEnabled && (
<Tab
id="authorization"
eventKey="authorization"
title={<TabTitleText>{t("authorization")}</TabTitleText>}
>
<Tabs
activeKey={authorizationSubTab}
onSelect={(_, key) => setAuthorizationSubTab(key as number)}
>
<Tab
id="settings"
eventKey={40}
title={<TabTitleText>{t("settings")}</TabTitleText>}
>
<AuthorizationSettings clientId={clientId} />
</Tab>
<Tab
id="resources"
eventKey={41}
title={<TabTitleText>{t("resources")}</TabTitleText>}
>
<AuthorizationResources clientId={clientId} />
</Tab>
</Tabs>
</Tab>
)}
{client!.serviceAccountsEnabled && (
<Tab
id="serviceAccount"

View file

@ -0,0 +1,90 @@
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import {
DescriptionList,
DescriptionListGroup,
DescriptionListTerm,
DescriptionListDescription,
} from "@patternfly/react-core";
import type ResourceServerRepresentation from "@keycloak/keycloak-admin-client/lib/defs/resourceServerRepresentation";
import { useAdminClient, useFetch } from "../../context/auth/AdminClient";
import "./detail-cell.css";
type Scope = { id: string; name: string }[];
type DetailCellProps = {
id: string;
clientId: string;
uris?: string[];
};
export const DetailCell = ({ id, clientId, uris }: DetailCellProps) => {
const { t } = useTranslation("clients");
const adminClient = useAdminClient();
const [scope, setScope] = useState<Scope>();
const [permissions, setPermissions] =
useState<ResourceServerRepresentation[]>();
useFetch(
() =>
Promise.all([
adminClient.clients.listScopesByResource({
id: clientId,
resourceName: id,
}),
adminClient.clients.listPermissionsByResource({
id: clientId,
resourceId: id,
}),
]),
([scopes, permissions]) => {
setScope(scopes);
setPermissions(permissions);
},
[]
);
return (
<DescriptionList isHorizontal className="keycloak_resource_details">
{uris?.length !== 0 && (
<DescriptionListGroup>
<DescriptionListTerm>{t("uris")}</DescriptionListTerm>
<DescriptionListDescription>
{uris?.map((uri) => (
<span key={uri} className="pf-u-pr-sm">
{uri}
</span>
))}
</DescriptionListDescription>
</DescriptionListGroup>
)}
{scope?.length !== 0 && (
<DescriptionListGroup>
<DescriptionListTerm>{t("scopes")}</DescriptionListTerm>
<DescriptionListDescription>
{scope?.map((scope) => (
<span key={scope.id} className="pf-u-pr-sm">
{scope.name}
</span>
))}
</DescriptionListDescription>
</DescriptionListGroup>
)}
{permissions?.length !== 0 && (
<DescriptionListGroup>
<DescriptionListTerm>
{t("associatedPermissions")}
</DescriptionListTerm>
<DescriptionListDescription>
{permissions?.map((permission) => (
<span key={permission.id} className="pf-u-pr-sm">
{permission.name}
</span>
))}
</DescriptionListDescription>
</DescriptionListGroup>
)}
</DescriptionList>
);
};

View file

@ -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<ExpandableResourceRepresentation[]>();
const [selectedResource, setSelectedResource] =
useState<ResourceRepresentation>();
const [permissions, setPermission] =
useState<ResourceServerRepresentation[]>();
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 && (
<Label color="blue">
{t("common:more", { count: (row.uris?.length || 1) - 1 })}
</Label>
)}
</>
);
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 && (
<Alert
variant="warning"
isInline
isPlain
title={t("deleteResourceWarning")}
className="pf-u-pt-lg"
>
<p className="pf-u-pt-xs">
{permissions.map((permission) => (
<strong key={permission.id} className="pf-u-pr-md">
{permission.name}
</strong>
))}
</p>
</Alert>
)}
</>
),
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 <Spinner />;
}
return (
<PageSection variant="light" className="pf-u-p-0">
<DeleteConfirm />
<PaginatingTableToolbar
count={resources.length}
first={first}
max={max}
onNextClick={setFirst}
onPreviousClick={setFirst}
onPerPageSelect={(first, max) => {
setFirst(first);
setMax(max);
}}
>
<TableComposable aria-label={t("resources")} variant="compact">
<Thead>
<Tr>
<Th />
<Th>{t("common:name")}</Th>
<Th>{t("common:type")}</Th>
<Th>{t("owner")}</Th>
<Th>{t("uris")}</Th>
<Th />
</Tr>
</Thead>
{resources.map((resource, rowIndex) => (
<Tbody key={resource._id} isExpanded={resource.isExpanded}>
<Tr>
<Td
expand={{
rowIndex,
isExpanded: resource.isExpanded,
onToggle: (_, rowIndex) => {
const rows = resources.map((resource, index) =>
index === rowIndex
? { ...resource, isExpanded: !resource.isExpanded }
: resource
);
setResources(rows);
},
}}
/>
<Td>{resource.name}</Td>
<Td>{resource.type}</Td>
<Td>{resource.owner?.name}</Td>
<Td>
<UriRenderer row={resource} />
</Td>
<Td
actions={{
items: [
{
title: t("common:delete"),
onClick: async () => {
setSelectedResource(resource);
setPermission(await fetchPermissions(resource._id!));
toggleDeleteDialog();
},
},
{
title: t("createPermission"),
className: "pf-m-link",
isOutsideDropdown: true,
},
],
}}
></Td>
</Tr>
<Tr
key={`child-${resource._id}`}
isExpanded={resource.isExpanded}
>
<Td colSpan={5}>
<ExpandableRowContent>
{resource.isExpanded && (
<DetailCell
clientId={clientId}
id={resource._id!}
uris={resource.uris}
/>
)}
</ExpandableRowContent>
</Td>
</Tr>
</Tbody>
))}
</TableComposable>
</PaginatingTableToolbar>
</PageSection>
);
};

View file

@ -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<ResourceServerRepresentation>();
const { control, reset } = useForm<ResourceServerRepresentation>({
shouldUnregister: false,
});
const adminClient = useAdminClient();
useFetch(
() => adminClient.clients.getResourceServer({ id: clientId }),
(resource) => {
setResource(resource);
reset(resource);
},
[]
);
if (!resource) {
return <Spinner />;
}
return (
<PageSection variant="light">
<FormAccess role="manage-clients" isHorizontal>
<FormGroup
label={t("import")}
fieldId="import"
labelIcon={
<HelpItem
helpText="clients-help:import"
forLabel={t("import")}
forID={t(`common:helpLabel`, { label: t("import") })}
/>
}
>
<Button variant="secondary">{t("import")}</Button>
</FormGroup>
<Divider />
<FormGroup
label={t("policyEnforcementMode")}
labelIcon={
<HelpItem
helpText="clients-help:policyEnforcementMode"
forLabel={t("policyEnforcementMode")}
forID="policyEnforcementMode"
/>
}
fieldId="policyEnforcementMode"
hasNoPaddingTop
>
<Controller
name="policyEnforcementMode"
data-testid="policyEnforcementMode"
defaultValue={DECISION_STRATEGY[0]}
control={control}
render={({ onChange, value }) => (
<>
{POLICY_ENFORCEMENT_MODES.map((mode) => (
<Radio
id={mode}
key={mode}
data-testid={mode}
isChecked={value === mode}
name="policyEnforcementMode"
onChange={() => onChange(mode)}
label={t(`policyEnforcementModes.${mode}`)}
className="pf-u-mb-md"
/>
))}
</>
)}
/>
</FormGroup>
<FormGroup
label={t("decisionStrategy")}
labelIcon={
<HelpItem
helpText="clients-help:decisionStrategy"
forLabel={t("decisionStrategy")}
forID="decisionStrategy"
/>
}
fieldId="decisionStrategy"
hasNoPaddingTop
>
<Controller
name="decisionStrategy"
data-testid="decisionStrategy"
defaultValue={DECISION_STRATEGY[0]}
control={control}
render={({ onChange, value }) => (
<>
{DECISION_STRATEGY.map((strategy) => (
<Radio
id={strategy}
key={strategy}
data-testid={strategy}
isChecked={value === strategy}
name="decisionStrategy"
onChange={() => onChange(strategy)}
label={t(`decisionStrategies.${strategy}`)}
className="pf-u-mb-md"
/>
))}
</>
)}
/>
</FormGroup>
<FormGroup
hasNoPaddingTop
label={t("allowRemoteResourceManagement")}
fieldId="allowRemoteResourceManagement"
labelIcon={
<HelpItem
helpText={t("allowRemoteResourceManagement")}
forLabel={t("allowRemoteResourceManagement")}
forID={"allowRemoteResourceManagement"}
/>
}
>
<Controller
name="allowRemoteResourceManagement"
data-testid="allowRemoteResourceManagement"
defaultValue={false}
control={control}
render={({ onChange, value }) => (
<Switch
id="allowRemoteResourceManagement"
label={t("common:on")}
labelOff={t("common:off")}
isChecked={value}
onChange={onChange}
/>
)}
/>
</FormGroup>
<SaveReset
name="settings"
save={(): void => {
// another PR
}}
reset={() => reset(resource)}
/>
</FormAccess>
</PageSection>
);
};

View file

@ -0,0 +1,3 @@
.keycloak_resource_details {
--pf-c-description-list--m-horizontal__term--width: 20ch;
}

View file

@ -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.",
},
};

View file

@ -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",

View file

@ -52,6 +52,7 @@ export default {
show: "Show",
hide: "Hide",
showRemaining: "Show ${remaining}",
more: "{{count}} more",
test: "Test",
testConnection: "Test connection",
name: "Name",