initial version of the scopes screen (#1750)
* initial version of the scopes screen * added scopes edit details * Update src/clients/authorization/MoreLabel.tsx Co-authored-by: Jon Koops <jonkoops@gmail.com> * pr review * Update src/clients/routes/NewScope.ts Co-authored-by: Jon Koops <jonkoops@gmail.com> * Update src/clients/authorization/Scopes.tsx Co-authored-by: Jon Koops <jonkoops@gmail.com> * Update src/clients/authorization/Scopes.tsx Co-authored-by: Jon Koops <jonkoops@gmail.com> * merge fix Co-authored-by: Jon Koops <jonkoops@gmail.com>
This commit is contained in:
parent
f0fc136672
commit
e6d8ffb202
16 changed files with 686 additions and 39 deletions
30
package-lock.json
generated
30
package-lock.json
generated
|
@ -7,7 +7,7 @@
|
|||
"name": "keycloak-admin-ui",
|
||||
"license": "Apache",
|
||||
"dependencies": {
|
||||
"@keycloak/keycloak-admin-client": "^16.1.0",
|
||||
"@keycloak/keycloak-admin-client": "^17.0.0-dev.5",
|
||||
"@patternfly/patternfly": "^4.164.2",
|
||||
"@patternfly/react-code-editor": "^4.22.1",
|
||||
"@patternfly/react-core": "^4.181.1",
|
||||
|
@ -3413,13 +3413,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@keycloak/keycloak-admin-client": {
|
||||
"version": "16.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@keycloak/keycloak-admin-client/-/keycloak-admin-client-16.1.0.tgz",
|
||||
"integrity": "sha512-QEibP/Jap+cwU/xB79eQQojBnNdBrWiatr98ARtKZSpyIOh0XYe4FB6YzsgGYj343KygSDLqjhAZ9nurHx64Rw==",
|
||||
"version": "17.0.0-dev.5",
|
||||
"resolved": "https://registry.npmjs.org/@keycloak/keycloak-admin-client/-/keycloak-admin-client-17.0.0-dev.5.tgz",
|
||||
"integrity": "sha512-WR+5eBunhyDMAErMqu3cUT1cSOZEhb8ie4QuIBNlZASeffXQQJdlosrA8kOkxUFo+SEYycuatKE+fkAD3+hFjw==",
|
||||
"dependencies": {
|
||||
"axios": "^0.24.0",
|
||||
"camelize-ts": "^1.0.8",
|
||||
"keycloak-js": "^15.0.2",
|
||||
"keycloak-js": "^16.0.0",
|
||||
"lodash": "^4.17.21",
|
||||
"query-string": "^7.0.1",
|
||||
"url-join": "^4.0.0",
|
||||
|
@ -14204,9 +14204,9 @@
|
|||
"dev": true
|
||||
},
|
||||
"node_modules/keycloak-js": {
|
||||
"version": "15.0.2",
|
||||
"resolved": "https://registry.npmjs.org/keycloak-js/-/keycloak-js-15.0.2.tgz",
|
||||
"integrity": "sha512-dv2a4NcPSH3AzGWG3ZtB+VrHpuQLdFBYXtQBj/+oBzm6XNwnVAMdL6LIC0OzCLQpn3rKTQJtNSATAGhbKJgewQ==",
|
||||
"version": "16.1.0",
|
||||
"resolved": "https://registry.npmjs.org/keycloak-js/-/keycloak-js-16.1.0.tgz",
|
||||
"integrity": "sha512-ydD0SJ+cLmtlor5MvyIOJygnGHueWwnAtXvqniv19k4TslcSpAEACTsnsvENdKa7/NTC4/erg6NctS4uF3nMdw==",
|
||||
"dependencies": {
|
||||
"base64-js": "1.3.1",
|
||||
"js-sha256": "0.9.0"
|
||||
|
@ -23870,13 +23870,13 @@
|
|||
}
|
||||
},
|
||||
"@keycloak/keycloak-admin-client": {
|
||||
"version": "16.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@keycloak/keycloak-admin-client/-/keycloak-admin-client-16.1.0.tgz",
|
||||
"integrity": "sha512-QEibP/Jap+cwU/xB79eQQojBnNdBrWiatr98ARtKZSpyIOh0XYe4FB6YzsgGYj343KygSDLqjhAZ9nurHx64Rw==",
|
||||
"version": "17.0.0-dev.5",
|
||||
"resolved": "https://registry.npmjs.org/@keycloak/keycloak-admin-client/-/keycloak-admin-client-17.0.0-dev.5.tgz",
|
||||
"integrity": "sha512-WR+5eBunhyDMAErMqu3cUT1cSOZEhb8ie4QuIBNlZASeffXQQJdlosrA8kOkxUFo+SEYycuatKE+fkAD3+hFjw==",
|
||||
"requires": {
|
||||
"axios": "^0.24.0",
|
||||
"camelize-ts": "^1.0.8",
|
||||
"keycloak-js": "^15.0.2",
|
||||
"keycloak-js": "^16.0.0",
|
||||
"lodash": "^4.17.21",
|
||||
"query-string": "^7.0.1",
|
||||
"url-join": "^4.0.0",
|
||||
|
@ -32374,9 +32374,9 @@
|
|||
"dev": true
|
||||
},
|
||||
"keycloak-js": {
|
||||
"version": "15.0.2",
|
||||
"resolved": "https://registry.npmjs.org/keycloak-js/-/keycloak-js-15.0.2.tgz",
|
||||
"integrity": "sha512-dv2a4NcPSH3AzGWG3ZtB+VrHpuQLdFBYXtQBj/+oBzm6XNwnVAMdL6LIC0OzCLQpn3rKTQJtNSATAGhbKJgewQ==",
|
||||
"version": "16.1.0",
|
||||
"resolved": "https://registry.npmjs.org/keycloak-js/-/keycloak-js-16.1.0.tgz",
|
||||
"integrity": "sha512-ydD0SJ+cLmtlor5MvyIOJygnGHueWwnAtXvqniv19k4TslcSpAEACTsnsvENdKa7/NTC4/erg6NctS4uF3nMdw==",
|
||||
"requires": {
|
||||
"base64-js": "1.3.1",
|
||||
"js-sha256": "0.9.0"
|
||||
|
|
|
@ -23,7 +23,7 @@
|
|||
"prepare": "husky install"
|
||||
},
|
||||
"dependencies": {
|
||||
"@keycloak/keycloak-admin-client": "^16.1.0",
|
||||
"@keycloak/keycloak-admin-client": "^17.0.0-dev.5",
|
||||
"@patternfly/patternfly": "^4.164.2",
|
||||
"@patternfly/react-code-editor": "^4.22.1",
|
||||
"@patternfly/react-core": "^4.181.1",
|
||||
|
|
|
@ -57,6 +57,7 @@ import type ProtocolMapperRepresentation from "@keycloak/keycloak-admin-client/l
|
|||
import { toMapper } from "./routes/Mapper";
|
||||
import { AuthorizationSettings } from "./authorization/Settings";
|
||||
import { AuthorizationResources } from "./authorization/Resources";
|
||||
import { AuthorizationScopes } from "./authorization/Scopes";
|
||||
|
||||
type ClientDetailHeaderProps = {
|
||||
onChange: (value: boolean) => void;
|
||||
|
@ -484,6 +485,13 @@ export default function ClientDetails() {
|
|||
>
|
||||
<AuthorizationResources clientId={clientId} />
|
||||
</Tab>
|
||||
<Tab
|
||||
id="scopes"
|
||||
eventKey={42}
|
||||
title={<TabTitleText>{t("scopes")}</TabTitleText>}
|
||||
>
|
||||
<AuthorizationScopes clientId={clientId} />
|
||||
</Tab>
|
||||
</Tabs>
|
||||
</Tab>
|
||||
)}
|
||||
|
|
75
src/clients/authorization/DeleteScopeDialog.tsx
Normal file
75
src/clients/authorization/DeleteScopeDialog.tsx
Normal file
|
@ -0,0 +1,75 @@
|
|||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Alert, AlertVariant } from "@patternfly/react-core";
|
||||
|
||||
import type { ExpandableScopeRepresentation } from "./Scopes";
|
||||
import { useAlerts } from "../../components/alert/Alerts";
|
||||
import { ConfirmDialogModal } from "../../components/confirm-dialog/ConfirmDialog";
|
||||
import { useAdminClient } from "../../context/auth/AdminClient";
|
||||
import type ScopeRepresentation from "@keycloak/keycloak-admin-client/lib/defs/scopeRepresentation";
|
||||
|
||||
type DeleteScopeDialogProps = {
|
||||
clientId: string;
|
||||
selectedScope:
|
||||
| ExpandableScopeRepresentation
|
||||
| ScopeRepresentation
|
||||
| undefined;
|
||||
refresh: () => void;
|
||||
open: boolean;
|
||||
toggleDialog: () => void;
|
||||
};
|
||||
|
||||
export const DeleteScopeDialog = ({
|
||||
clientId,
|
||||
selectedScope,
|
||||
refresh,
|
||||
open,
|
||||
toggleDialog,
|
||||
}: DeleteScopeDialogProps) => {
|
||||
const { t } = useTranslation("clients");
|
||||
const adminClient = useAdminClient();
|
||||
const { addAlert, addError } = useAlerts();
|
||||
|
||||
return (
|
||||
<ConfirmDialogModal
|
||||
open={open}
|
||||
toggleDialog={toggleDialog}
|
||||
titleKey="clients:deleteScope"
|
||||
continueButtonLabel="clients:confirm"
|
||||
onConfirm={async () => {
|
||||
try {
|
||||
await adminClient.clients.delAuthorizationScope({
|
||||
id: clientId,
|
||||
scopeId: selectedScope?.id!,
|
||||
});
|
||||
addAlert(t("resourceScopeSuccess"), AlertVariant.success);
|
||||
refresh();
|
||||
} catch (error) {
|
||||
addError("clients:resourceScopeError", error);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("deleteScopeConfirm")}
|
||||
{selectedScope &&
|
||||
"permissions" in selectedScope &&
|
||||
selectedScope.permissions &&
|
||||
selectedScope.permissions.length > 0 && (
|
||||
<Alert
|
||||
variant="warning"
|
||||
isInline
|
||||
isPlain
|
||||
title={t("deleteScopeWarning")}
|
||||
className="pf-u-pt-lg"
|
||||
>
|
||||
<p className="pf-u-pt-xs">
|
||||
{selectedScope.permissions.map((permission) => (
|
||||
<strong key={permission.id} className="pf-u-pr-md">
|
||||
{permission.name}
|
||||
</strong>
|
||||
))}
|
||||
</p>
|
||||
</Alert>
|
||||
)}
|
||||
</ConfirmDialogModal>
|
||||
);
|
||||
};
|
18
src/clients/authorization/MoreLabel.tsx
Normal file
18
src/clients/authorization/MoreLabel.tsx
Normal file
|
@ -0,0 +1,18 @@
|
|||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Label } from "@patternfly/react-core";
|
||||
|
||||
type MoreLabelProps = {
|
||||
array: unknown[] | undefined;
|
||||
};
|
||||
|
||||
export const MoreLabel = ({ array }: MoreLabelProps) => {
|
||||
const { t } = useTranslation("clients");
|
||||
|
||||
if (!array || array.length <= 1) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Label color="blue">{t("common:more", { count: array.length - 1 })}</Label>
|
||||
);
|
||||
};
|
|
@ -37,12 +37,6 @@ import { AttributeInput } from "../../components/attribute-input/AttributeInput"
|
|||
|
||||
import "./resource-details.css";
|
||||
|
||||
type FetchResource = {
|
||||
client?: ClientRepresentation;
|
||||
resource?: ResourceRepresentation;
|
||||
permissions?: ResourceServerRepresentation[];
|
||||
};
|
||||
|
||||
type SubmittedResource = Omit<ResourceRepresentation, "attributes" | "uris"> & {
|
||||
attributes: KeyValueType[];
|
||||
uris: MultiLine[];
|
||||
|
@ -72,8 +66,8 @@ export default function ResourceDetails() {
|
|||
};
|
||||
|
||||
useFetch(
|
||||
async (): Promise<FetchResource> => {
|
||||
const [client, resource, permissions] = await Promise.all([
|
||||
() =>
|
||||
Promise.all([
|
||||
adminClient.clients.findOne({ id }),
|
||||
resourceId
|
||||
? adminClient.clients.getResource({ id, resourceId })
|
||||
|
@ -81,11 +75,8 @@ export default function ResourceDetails() {
|
|||
resourceId
|
||||
? adminClient.clients.listPermissionsByResource({ id, resourceId })
|
||||
: Promise.resolve(undefined),
|
||||
]);
|
||||
|
||||
return { client, resource, permissions };
|
||||
},
|
||||
({ client, resource, permissions }) => {
|
||||
]),
|
||||
([client, resource, permissions]) => {
|
||||
if (!client) {
|
||||
throw new Error(t("common:notFound"));
|
||||
}
|
||||
|
|
|
@ -5,7 +5,6 @@ import {
|
|||
Alert,
|
||||
AlertVariant,
|
||||
Button,
|
||||
Label,
|
||||
PageSection,
|
||||
ToolbarItem,
|
||||
} from "@patternfly/react-core";
|
||||
|
@ -30,6 +29,7 @@ import { DetailCell } from "./DetailCell";
|
|||
import { toCreateResource } from "../routes/NewResource";
|
||||
import { useRealm } from "../../context/realm-context/RealmContext";
|
||||
import { toResourceDetails } from "../routes/Resource";
|
||||
import { MoreLabel } from "./MoreLabel";
|
||||
|
||||
type ResourcesProps = {
|
||||
clientId: string;
|
||||
|
@ -79,12 +79,7 @@ export const AuthorizationResources = ({ clientId }: ResourcesProps) => {
|
|||
|
||||
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>
|
||||
)}
|
||||
{row.uris?.[0]} <MoreLabel array={row.uris} />
|
||||
</>
|
||||
);
|
||||
|
||||
|
@ -229,7 +224,7 @@ export const AuthorizationResources = ({ clientId }: ResourcesProps) => {
|
|||
},
|
||||
],
|
||||
}}
|
||||
></Td>
|
||||
/>
|
||||
</Tr>
|
||||
<Tr
|
||||
key={`child-${resource._id}`}
|
||||
|
|
211
src/clients/authorization/ScopeDetails.tsx
Normal file
211
src/clients/authorization/ScopeDetails.tsx
Normal file
|
@ -0,0 +1,211 @@
|
|||
import React, { useState } from "react";
|
||||
import { Link, useHistory, useParams } from "react-router-dom";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ActionGroup,
|
||||
AlertVariant,
|
||||
Button,
|
||||
ButtonVariant,
|
||||
DropdownItem,
|
||||
FormGroup,
|
||||
PageSection,
|
||||
TextInput,
|
||||
ValidatedOptions,
|
||||
} from "@patternfly/react-core";
|
||||
|
||||
import type ScopeRepresentation from "@keycloak/keycloak-admin-client/lib/defs/scopeRepresentation";
|
||||
import type { ScopeDetailsParams } from "../routes/Scope";
|
||||
import { FormAccess } from "../../components/form-access/FormAccess";
|
||||
import { HelpItem } from "../../components/help-enabler/HelpItem";
|
||||
import { ViewHeader } from "../../components/view-header/ViewHeader";
|
||||
import { toClient } from "../routes/Client";
|
||||
import { useAdminClient, useFetch } from "../../context/auth/AdminClient";
|
||||
import { useAlerts } from "../../components/alert/Alerts";
|
||||
import useToggle from "../../utils/useToggle";
|
||||
import { DeleteScopeDialog } from "./DeleteScopeDialog";
|
||||
|
||||
export default function ScopeDetails() {
|
||||
const { t } = useTranslation("clients");
|
||||
const { id, scopeId, realm } = useParams<ScopeDetailsParams>();
|
||||
const history = useHistory();
|
||||
|
||||
const adminClient = useAdminClient();
|
||||
const { addAlert, addError } = useAlerts();
|
||||
|
||||
const [deleteDialog, toggleDeleteDialog] = useToggle();
|
||||
const [scope, setScope] = useState<ScopeRepresentation>();
|
||||
const { register, errors, reset, handleSubmit } =
|
||||
useForm<ScopeRepresentation>({
|
||||
mode: "onChange",
|
||||
});
|
||||
|
||||
useFetch(
|
||||
async () => {
|
||||
if (scopeId) {
|
||||
const scope = await adminClient.clients.getAuthorizationScope({
|
||||
id,
|
||||
scopeId,
|
||||
});
|
||||
if (!scope) {
|
||||
throw new Error(t("common:notFound"));
|
||||
}
|
||||
return scope;
|
||||
}
|
||||
},
|
||||
(scope) => {
|
||||
setScope(scope);
|
||||
reset({ ...scope });
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const save = async (scope: ScopeRepresentation) => {
|
||||
try {
|
||||
if (scopeId) {
|
||||
await adminClient.clients.updateAuthorizationScope(
|
||||
{ id, scopeId },
|
||||
scope
|
||||
);
|
||||
setScope(scope);
|
||||
} else {
|
||||
await adminClient.clients.createAuthorizationScope(
|
||||
{ id },
|
||||
{
|
||||
name: scope.name!,
|
||||
displayName: scope.displayName,
|
||||
iconUri: scope.iconUri,
|
||||
}
|
||||
);
|
||||
history.push(toClient({ realm, clientId: id, tab: "authorization" }));
|
||||
}
|
||||
addAlert(
|
||||
t((scopeId ? "update" : "create") + "ScopeSuccess"),
|
||||
AlertVariant.success
|
||||
);
|
||||
} catch (error) {
|
||||
addError("clients:scopeSaveError", error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DeleteScopeDialog
|
||||
clientId={id}
|
||||
open={deleteDialog}
|
||||
toggleDialog={toggleDeleteDialog}
|
||||
selectedScope={scope}
|
||||
refresh={() =>
|
||||
history.push(toClient({ realm, clientId: id, tab: "authorization" }))
|
||||
}
|
||||
/>
|
||||
<ViewHeader
|
||||
titleKey={"clients:createResource"}
|
||||
dropdownItems={
|
||||
scopeId
|
||||
? [
|
||||
<DropdownItem
|
||||
key="delete"
|
||||
data-testid="delete-resource"
|
||||
onClick={() => toggleDeleteDialog()}
|
||||
>
|
||||
{t("common:delete")}
|
||||
</DropdownItem>,
|
||||
]
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<PageSection variant="light">
|
||||
<FormAccess
|
||||
isHorizontal
|
||||
role="manage-clients"
|
||||
onSubmit={handleSubmit(save)}
|
||||
>
|
||||
<FormGroup
|
||||
label={t("common:name")}
|
||||
fieldId="name"
|
||||
labelIcon={
|
||||
<HelpItem helpText="clients-help:scopeName" fieldLabelId="name" />
|
||||
}
|
||||
helperTextInvalid={t("common:required")}
|
||||
validated={
|
||||
errors.name ? ValidatedOptions.error : ValidatedOptions.default
|
||||
}
|
||||
isRequired
|
||||
>
|
||||
<TextInput
|
||||
id="name"
|
||||
name="name"
|
||||
ref={register({ required: true })}
|
||||
validated={
|
||||
errors.name ? ValidatedOptions.error : ValidatedOptions.default
|
||||
}
|
||||
/>
|
||||
</FormGroup>
|
||||
<FormGroup
|
||||
label={t("displayName")}
|
||||
fieldId="displayName"
|
||||
labelIcon={
|
||||
<HelpItem
|
||||
helpText="clients-help:scopeDisplayName"
|
||||
fieldLabelId="displayName"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<TextInput id="displayName" name="displayName" ref={register} />
|
||||
</FormGroup>
|
||||
<FormGroup
|
||||
label={t("iconUri")}
|
||||
fieldId="iconUri"
|
||||
labelIcon={
|
||||
<HelpItem
|
||||
helpText="clients-help:iconUri"
|
||||
fieldLabelId="clients:iconUri"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<TextInput id="iconUri" name="iconUri" ref={register} />
|
||||
</FormGroup>
|
||||
<ActionGroup>
|
||||
<div className="pf-u-mt-md">
|
||||
<Button
|
||||
variant={ButtonVariant.primary}
|
||||
type="submit"
|
||||
data-testid="save"
|
||||
>
|
||||
{t("common:save")}
|
||||
</Button>
|
||||
|
||||
{!scope ? (
|
||||
<Button
|
||||
variant="link"
|
||||
data-testid="cancel"
|
||||
component={(props) => (
|
||||
<Link
|
||||
{...props}
|
||||
to={toClient({
|
||||
realm,
|
||||
clientId: id,
|
||||
tab: "authorization",
|
||||
})}
|
||||
></Link>
|
||||
)}
|
||||
>
|
||||
{t("common:cancel")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="link"
|
||||
data-testid="revert"
|
||||
onClick={() => reset({ ...scope })}
|
||||
>
|
||||
{t("common:revert")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</ActionGroup>
|
||||
</FormAccess>
|
||||
</PageSection>
|
||||
</>
|
||||
);
|
||||
}
|
283
src/clients/authorization/Scopes.tsx
Normal file
283
src/clients/authorization/Scopes.tsx
Normal file
|
@ -0,0 +1,283 @@
|
|||
import React, { useState } from "react";
|
||||
import { Link, useHistory } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Button,
|
||||
DescriptionList,
|
||||
DescriptionListDescription,
|
||||
DescriptionListGroup,
|
||||
DescriptionListTerm,
|
||||
PageSection,
|
||||
ToolbarItem,
|
||||
} from "@patternfly/react-core";
|
||||
import {
|
||||
ExpandableRowContent,
|
||||
TableComposable,
|
||||
Tbody,
|
||||
Td,
|
||||
Th,
|
||||
Thead,
|
||||
Tr,
|
||||
} from "@patternfly/react-table";
|
||||
|
||||
import type ScopeRepresentation from "@keycloak/keycloak-admin-client/lib/defs/scopeRepresentation";
|
||||
import type PolicyRepresentation from "@keycloak/keycloak-admin-client/lib/defs/policyRepresentation";
|
||||
|
||||
import { KeycloakSpinner } from "../../components/keycloak-spinner/KeycloakSpinner";
|
||||
import { PaginatingTableToolbar } from "../../components/table-toolbar/PaginatingTableToolbar";
|
||||
import { useAdminClient, useFetch } from "../../context/auth/AdminClient";
|
||||
import { useRealm } from "../../context/realm-context/RealmContext";
|
||||
import { MoreLabel } from "./MoreLabel";
|
||||
import { toScopeDetails } from "../routes/Scope";
|
||||
import { toNewScope } from "../routes/NewScope";
|
||||
import { ListEmptyState } from "../../components/list-empty-state/ListEmptyState";
|
||||
import useToggle from "../../utils/useToggle";
|
||||
import { DeleteScopeDialog } from "./DeleteScopeDialog";
|
||||
|
||||
type ScopesProps = {
|
||||
clientId: string;
|
||||
};
|
||||
|
||||
export type ExpandableScopeRepresentation = ScopeRepresentation & {
|
||||
permissions?: PolicyRepresentation[];
|
||||
isExpanded: boolean;
|
||||
};
|
||||
|
||||
export const AuthorizationScopes = ({ clientId }: ScopesProps) => {
|
||||
const { t } = useTranslation("clients");
|
||||
const history = useHistory();
|
||||
const adminClient = useAdminClient();
|
||||
const { realm } = useRealm();
|
||||
|
||||
const [deleteDialog, toggleDeleteDialog] = useToggle();
|
||||
const [scopes, setScopes] = useState<ExpandableScopeRepresentation[]>();
|
||||
const [selectedScope, setSelectedScope] =
|
||||
useState<ExpandableScopeRepresentation>();
|
||||
|
||||
const [key, setKey] = useState(0);
|
||||
const refresh = () => setKey(key + 1);
|
||||
|
||||
const [max, setMax] = useState(10);
|
||||
const [first, setFirst] = useState(0);
|
||||
|
||||
useFetch(
|
||||
async () => {
|
||||
const params = {
|
||||
first,
|
||||
max,
|
||||
deep: false,
|
||||
};
|
||||
const scopes = await adminClient.clients.listAllScopes({
|
||||
...params,
|
||||
id: clientId,
|
||||
});
|
||||
|
||||
return await Promise.all(
|
||||
scopes.map(async (scope) => {
|
||||
const options = { id: clientId, scopeId: scope.id! };
|
||||
const [resources, permissions] = await Promise.all([
|
||||
adminClient.clients.listAllResourcesByScope(options),
|
||||
adminClient.clients.listAllPermissionsByScope(options),
|
||||
]);
|
||||
|
||||
return {
|
||||
...scope,
|
||||
resources,
|
||||
permissions,
|
||||
isExpanded: false,
|
||||
};
|
||||
})
|
||||
);
|
||||
},
|
||||
setScopes,
|
||||
[key]
|
||||
);
|
||||
|
||||
const ResourceRenderer = ({
|
||||
row,
|
||||
}: {
|
||||
row: ExpandableScopeRepresentation;
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
{row.resources?.[0]?.name} <MoreLabel array={row.resources} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const PermissionsRenderer = ({
|
||||
row,
|
||||
}: {
|
||||
row: ExpandableScopeRepresentation;
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
{row.permissions?.[0]?.name} <MoreLabel array={row.permissions} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
if (!scopes) {
|
||||
return <KeycloakSpinner />;
|
||||
}
|
||||
|
||||
return (
|
||||
<PageSection variant="light" className="pf-u-p-0">
|
||||
<DeleteScopeDialog
|
||||
clientId={clientId}
|
||||
open={deleteDialog}
|
||||
toggleDialog={toggleDeleteDialog}
|
||||
selectedScope={selectedScope}
|
||||
refresh={refresh}
|
||||
/>
|
||||
{scopes.length > 0 && (
|
||||
<PaginatingTableToolbar
|
||||
count={scopes.length}
|
||||
first={first}
|
||||
max={max}
|
||||
onNextClick={setFirst}
|
||||
onPreviousClick={setFirst}
|
||||
onPerPageSelect={(first, max) => {
|
||||
setFirst(first);
|
||||
setMax(max);
|
||||
}}
|
||||
toolbarItem={
|
||||
<ToolbarItem>
|
||||
<Button
|
||||
data-testid="createAuthorizationScope"
|
||||
component={(props) => (
|
||||
<Link {...props} to={toNewScope({ realm, id: clientId })} />
|
||||
)}
|
||||
>
|
||||
{t("createAuthorizationScope")}
|
||||
</Button>
|
||||
</ToolbarItem>
|
||||
}
|
||||
>
|
||||
<TableComposable aria-label={t("scopes")} variant="compact">
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th />
|
||||
<Th>{t("common:name")}</Th>
|
||||
<Th>{t("resources")}</Th>
|
||||
<Th>{t("permissions")}</Th>
|
||||
<Th />
|
||||
</Tr>
|
||||
</Thead>
|
||||
{scopes.map((scope, rowIndex) => (
|
||||
<Tbody key={scope.id} isExpanded={scope.isExpanded}>
|
||||
<Tr>
|
||||
<Td
|
||||
expand={{
|
||||
rowIndex,
|
||||
isExpanded: scope.isExpanded,
|
||||
onToggle: (_, rowIndex) => {
|
||||
const rows = scopes.map((resource, index) =>
|
||||
index === rowIndex
|
||||
? { ...resource, isExpanded: !resource.isExpanded }
|
||||
: resource
|
||||
);
|
||||
setScopes(rows);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Td data-testid={`name-column-${scope.name}`}>
|
||||
<Link
|
||||
to={toScopeDetails({
|
||||
realm,
|
||||
id: clientId,
|
||||
scopeId: scope.id!,
|
||||
})}
|
||||
>
|
||||
{scope.name}
|
||||
</Link>
|
||||
</Td>
|
||||
<Td>
|
||||
<ResourceRenderer row={scope} />
|
||||
</Td>
|
||||
<Td>
|
||||
<PermissionsRenderer row={scope} />
|
||||
</Td>
|
||||
<Td
|
||||
actions={{
|
||||
items: [
|
||||
{
|
||||
title: t("common:delete"),
|
||||
onClick: () => {
|
||||
setSelectedScope(scope);
|
||||
toggleDeleteDialog();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t("createPermission"),
|
||||
className: "pf-m-link",
|
||||
isOutsideDropdown: true,
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
</Tr>
|
||||
<Tr key={`child-${scope.id}`} isExpanded={scope.isExpanded}>
|
||||
<Td colSpan={5}>
|
||||
<ExpandableRowContent>
|
||||
{scope.isExpanded && (
|
||||
<DescriptionList
|
||||
isHorizontal
|
||||
className="keycloak_resource_details"
|
||||
>
|
||||
<DescriptionListGroup>
|
||||
<DescriptionListTerm>
|
||||
{t("resources")}
|
||||
</DescriptionListTerm>
|
||||
<DescriptionListDescription>
|
||||
{scope.resources?.map((resource) => (
|
||||
<span key={resource._id} className="pf-u-pr-sm">
|
||||
{resource.name}
|
||||
</span>
|
||||
))}
|
||||
{scope.resources?.length === 0 && (
|
||||
<i>{t("common:none")}</i>
|
||||
)}
|
||||
</DescriptionListDescription>
|
||||
</DescriptionListGroup>
|
||||
<DescriptionListGroup>
|
||||
<DescriptionListTerm>
|
||||
{t("associatedPermissions")}
|
||||
</DescriptionListTerm>
|
||||
<DescriptionListDescription>
|
||||
{scope.permissions?.map((permission) => (
|
||||
<span
|
||||
key={permission.id}
|
||||
className="pf-u-pr-sm"
|
||||
>
|
||||
{permission.name}
|
||||
</span>
|
||||
))}
|
||||
{scope.permissions?.length === 0 && (
|
||||
<i>{t("common:none")}</i>
|
||||
)}
|
||||
</DescriptionListDescription>
|
||||
</DescriptionListGroup>
|
||||
</DescriptionList>
|
||||
)}
|
||||
</ExpandableRowContent>
|
||||
</Td>
|
||||
</Tr>
|
||||
</Tbody>
|
||||
))}
|
||||
</TableComposable>
|
||||
</PaginatingTableToolbar>
|
||||
)}
|
||||
{scopes.length === 0 && (
|
||||
<ListEmptyState
|
||||
message={t("emptyAuthorizationScopes")}
|
||||
instructions={t("emptyAuthorizationInstructions")}
|
||||
onPrimaryAction={() =>
|
||||
history.push(toNewScope({ id: clientId, realm }))
|
||||
}
|
||||
primaryActionText={t("createAuthorizationScope")}
|
||||
/>
|
||||
)}
|
||||
</PageSection>
|
||||
);
|
||||
};
|
|
@ -176,5 +176,9 @@ export default {
|
|||
resetActions:
|
||||
"Set of actions to execute when sending the user a Reset Actions Email. 'Verify email' sends an email to the user to verify their email address. 'Update profile' requires user to enter in new personal information. 'Update password' requires user to enter in a new password. 'Configure OTP' requires setup of a mobile password generator.",
|
||||
lifespan: "Maximum time before the action permit expires.",
|
||||
scopeName:
|
||||
"A unique name for this scope. The name can be used to uniquely identify a scope, useful when querying for a specific scope.",
|
||||
scopeDisplayName:
|
||||
"A unique name for this scope. The name can be used to uniquely identify a scope, useful when querying for a specific scope.",
|
||||
},
|
||||
};
|
||||
|
|
|
@ -93,6 +93,22 @@ export default {
|
|||
"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}}",
|
||||
deleteScope: "Permanently delete authorization scope?",
|
||||
deleteScopeConfirm:
|
||||
"If you delete this authorization scope, some permissions will be affected.",
|
||||
deleteScopeWarning:
|
||||
"The permissions below will be removed when they are no longer used by other authorization scopes:",
|
||||
resourceScopeSuccess: "The authorization scope successfully deleted",
|
||||
resourceScopeError:
|
||||
"Could not remove the authorization scope due to {{error}}",
|
||||
createAuthorizationScope: "Create authorization scope",
|
||||
permissions: "Permissions",
|
||||
emptyAuthorizationScopes: "No authorization scopes",
|
||||
emptyAuthorizationInstructions:
|
||||
"If you want to create authorization scopes, please click the button below to create the authorization scope",
|
||||
createScopeSuccess: "Authorization scope created successfully",
|
||||
updateScopeSuccess: "Authorization scope successfully updated",
|
||||
scopeSaveError: "Could not persist authorization scope due to {{error}}",
|
||||
assignedClientScope: "Assigned client scope",
|
||||
assignedType: "Assigned type",
|
||||
hideInheritedRoles: "Hide inherited roles",
|
||||
|
|
|
@ -7,6 +7,8 @@ import { ImportClientRoute } from "./routes/ImportClient";
|
|||
import { MapperRoute } from "./routes/Mapper";
|
||||
import { NewResourceRoute } from "./routes/NewResource";
|
||||
import { ResourceDetailsRoute } from "./routes/Resource";
|
||||
import { NewScopeRoute } from "./routes/NewScope";
|
||||
import { ScopeDetailsRoute } from "./routes/Scope";
|
||||
|
||||
const routes: RouteDef[] = [
|
||||
AddClientRoute,
|
||||
|
@ -17,6 +19,8 @@ const routes: RouteDef[] = [
|
|||
MapperRoute,
|
||||
NewResourceRoute,
|
||||
ResourceDetailsRoute,
|
||||
NewScopeRoute,
|
||||
ScopeDetailsRoute,
|
||||
];
|
||||
|
||||
export default routes;
|
||||
|
|
|
@ -6,7 +6,7 @@ import { lazy } from "react";
|
|||
export type NewResourceParams = { realm: string; id: string };
|
||||
|
||||
export const NewResourceRoute: RouteDef = {
|
||||
path: "/:realm/clients/:id/authorization/new",
|
||||
path: "/:realm/clients/:id/authorization/resource/new",
|
||||
component: lazy(() => import("../authorization/ResourceDetails")),
|
||||
breadcrumb: (t) => t("clients:createResource"),
|
||||
access: "manage-clients",
|
||||
|
|
19
src/clients/routes/NewScope.ts
Normal file
19
src/clients/routes/NewScope.ts
Normal file
|
@ -0,0 +1,19 @@
|
|||
import type { LocationDescriptorObject } from "history";
|
||||
import type { RouteDef } from "../../route-config";
|
||||
import { generatePath } from "react-router-dom";
|
||||
import { lazy } from "react";
|
||||
|
||||
export type NewScopeParams = { realm: string; id: string };
|
||||
|
||||
export const NewScopeRoute: RouteDef = {
|
||||
path: "/:realm/clients/:id/authorization/scope/new",
|
||||
component: lazy(() => import("../authorization/ScopeDetails")),
|
||||
breadcrumb: (t) => t("clients:createAuthorizationScope"),
|
||||
access: "manage-clients",
|
||||
};
|
||||
|
||||
export const toNewScope = (
|
||||
params: NewScopeParams
|
||||
): LocationDescriptorObject => ({
|
||||
pathname: generatePath(NewScopeRoute.path, params),
|
||||
});
|
|
@ -10,7 +10,7 @@ export type ResourceDetailsParams = {
|
|||
};
|
||||
|
||||
export const ResourceDetailsRoute: RouteDef = {
|
||||
path: "/:realm/clients/:id/authorization/:resourceId?",
|
||||
path: "/:realm/clients/:id/authorization/resource/:resourceId?",
|
||||
component: lazy(() => import("../authorization/ResourceDetails")),
|
||||
breadcrumb: (t) => t("clients:createResource"),
|
||||
access: "manage-clients",
|
||||
|
|
23
src/clients/routes/Scope.ts
Normal file
23
src/clients/routes/Scope.ts
Normal file
|
@ -0,0 +1,23 @@
|
|||
import type { LocationDescriptorObject } from "history";
|
||||
import type { RouteDef } from "../../route-config";
|
||||
import { generatePath } from "react-router-dom";
|
||||
import { lazy } from "react";
|
||||
|
||||
export type ScopeDetailsParams = {
|
||||
realm: string;
|
||||
id: string;
|
||||
scopeId?: string;
|
||||
};
|
||||
|
||||
export const ScopeDetailsRoute: RouteDef = {
|
||||
path: "/:realm/clients/:id/authorization/scope/:scopeId?",
|
||||
component: lazy(() => import("../authorization/ScopeDetails")),
|
||||
breadcrumb: (t) => t("clients:createAuthorizationScope"),
|
||||
access: "manage-clients",
|
||||
};
|
||||
|
||||
export const toScopeDetails = (
|
||||
params: ScopeDetailsParams
|
||||
): LocationDescriptorObject => ({
|
||||
pathname: generatePath(ScopeDetailsRoute.path, params),
|
||||
});
|
Loading…
Reference in a new issue