From e6d8ffb202b22c31db8e0ed3ccb1a7de7c2614a5 Mon Sep 17 00:00:00 2001 From: Erik Jan de Wit Date: Tue, 4 Jan 2022 10:17:43 +0100 Subject: [PATCH] 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 * pr review * Update src/clients/routes/NewScope.ts Co-authored-by: Jon Koops * Update src/clients/authorization/Scopes.tsx Co-authored-by: Jon Koops * Update src/clients/authorization/Scopes.tsx Co-authored-by: Jon Koops * merge fix Co-authored-by: Jon Koops --- package-lock.json | 30 +- package.json | 2 +- src/clients/ClientDetails.tsx | 8 + .../authorization/DeleteScopeDialog.tsx | 75 +++++ src/clients/authorization/MoreLabel.tsx | 18 ++ src/clients/authorization/ResourceDetails.tsx | 17 +- src/clients/authorization/Resources.tsx | 11 +- src/clients/authorization/ScopeDetails.tsx | 211 +++++++++++++ src/clients/authorization/Scopes.tsx | 283 ++++++++++++++++++ src/clients/help.ts | 4 + src/clients/messages.ts | 16 + src/clients/routes.ts | 4 + src/clients/routes/NewResource.ts | 2 +- src/clients/routes/NewScope.ts | 19 ++ src/clients/routes/Resource.ts | 2 +- src/clients/routes/Scope.ts | 23 ++ 16 files changed, 686 insertions(+), 39 deletions(-) create mode 100644 src/clients/authorization/DeleteScopeDialog.tsx create mode 100644 src/clients/authorization/MoreLabel.tsx create mode 100644 src/clients/authorization/ScopeDetails.tsx create mode 100644 src/clients/authorization/Scopes.tsx create mode 100644 src/clients/routes/NewScope.ts create mode 100644 src/clients/routes/Scope.ts diff --git a/package-lock.json b/package-lock.json index 6cb045bd29..65843aba61 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" diff --git a/package.json b/package.json index ac1fa83aed..98b5e91899 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/clients/ClientDetails.tsx b/src/clients/ClientDetails.tsx index d456e639a5..6f35a261eb 100644 --- a/src/clients/ClientDetails.tsx +++ b/src/clients/ClientDetails.tsx @@ -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() { > + {t("scopes")}} + > + + )} diff --git a/src/clients/authorization/DeleteScopeDialog.tsx b/src/clients/authorization/DeleteScopeDialog.tsx new file mode 100644 index 0000000000..89a9448c70 --- /dev/null +++ b/src/clients/authorization/DeleteScopeDialog.tsx @@ -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 ( + { + 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 && ( + +

+ {selectedScope.permissions.map((permission) => ( + + {permission.name} + + ))} +

+
+ )} +
+ ); +}; diff --git a/src/clients/authorization/MoreLabel.tsx b/src/clients/authorization/MoreLabel.tsx new file mode 100644 index 0000000000..1051e8ca1e --- /dev/null +++ b/src/clients/authorization/MoreLabel.tsx @@ -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 ( + + ); +}; diff --git a/src/clients/authorization/ResourceDetails.tsx b/src/clients/authorization/ResourceDetails.tsx index 808bd4ab8e..5f2987b089 100644 --- a/src/clients/authorization/ResourceDetails.tsx +++ b/src/clients/authorization/ResourceDetails.tsx @@ -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 & { attributes: KeyValueType[]; uris: MultiLine[]; @@ -72,8 +66,8 @@ export default function ResourceDetails() { }; useFetch( - async (): Promise => { - 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")); } diff --git a/src/clients/authorization/Resources.tsx b/src/clients/authorization/Resources.tsx index 763f881f9d..1565c5a9c6 100644 --- a/src/clients/authorization/Resources.tsx +++ b/src/clients/authorization/Resources.tsx @@ -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 && ( - - )} + {row.uris?.[0]} ); @@ -229,7 +224,7 @@ export const AuthorizationResources = ({ clientId }: ResourcesProps) => { }, ], }} - > + /> (); + const history = useHistory(); + + const adminClient = useAdminClient(); + const { addAlert, addError } = useAlerts(); + + const [deleteDialog, toggleDeleteDialog] = useToggle(); + const [scope, setScope] = useState(); + const { register, errors, reset, handleSubmit } = + useForm({ + 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 ( + <> + + history.push(toClient({ realm, clientId: id, tab: "authorization" })) + } + /> + toggleDeleteDialog()} + > + {t("common:delete")} + , + ] + : undefined + } + /> + + + + } + helperTextInvalid={t("common:required")} + validated={ + errors.name ? ValidatedOptions.error : ValidatedOptions.default + } + isRequired + > + + + + } + > + + + + } + > + + + +
+ + + {!scope ? ( + + ) : ( + + )} +
+
+
+
+ + ); +} diff --git a/src/clients/authorization/Scopes.tsx b/src/clients/authorization/Scopes.tsx new file mode 100644 index 0000000000..5f565c96a5 --- /dev/null +++ b/src/clients/authorization/Scopes.tsx @@ -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(); + const [selectedScope, setSelectedScope] = + useState(); + + 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} + + ); + }; + + const PermissionsRenderer = ({ + row, + }: { + row: ExpandableScopeRepresentation; + }) => { + return ( + <> + {row.permissions?.[0]?.name} + + ); + }; + + if (!scopes) { + return ; + } + + return ( + + + {scopes.length > 0 && ( + { + setFirst(first); + setMax(max); + }} + toolbarItem={ + + + + } + > + + + + + {t("common:name")} + {t("resources")} + {t("permissions")} + + + + {scopes.map((scope, rowIndex) => ( + + + { + const rows = scopes.map((resource, index) => + index === rowIndex + ? { ...resource, isExpanded: !resource.isExpanded } + : resource + ); + setScopes(rows); + }, + }} + /> + + + {scope.name} + + + + + + + + + { + setSelectedScope(scope); + toggleDeleteDialog(); + }, + }, + { + title: t("createPermission"), + className: "pf-m-link", + isOutsideDropdown: true, + }, + ], + }} + /> + + + + + {scope.isExpanded && ( + + + + {t("resources")} + + + {scope.resources?.map((resource) => ( + + {resource.name} + + ))} + {scope.resources?.length === 0 && ( + {t("common:none")} + )} + + + + + {t("associatedPermissions")} + + + {scope.permissions?.map((permission) => ( + + {permission.name} + + ))} + {scope.permissions?.length === 0 && ( + {t("common:none")} + )} + + + + )} + + + + + ))} + + + )} + {scopes.length === 0 && ( + + history.push(toNewScope({ id: clientId, realm })) + } + primaryActionText={t("createAuthorizationScope")} + /> + )} + + ); +}; diff --git a/src/clients/help.ts b/src/clients/help.ts index 8d6885832d..dc93f96ef2 100644 --- a/src/clients/help.ts +++ b/src/clients/help.ts @@ -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.", }, }; diff --git a/src/clients/messages.ts b/src/clients/messages.ts index 44c7698239..7dbf9c51f1 100644 --- a/src/clients/messages.ts +++ b/src/clients/messages.ts @@ -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", diff --git a/src/clients/routes.ts b/src/clients/routes.ts index 17e03c37a0..c950273d48 100644 --- a/src/clients/routes.ts +++ b/src/clients/routes.ts @@ -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; diff --git a/src/clients/routes/NewResource.ts b/src/clients/routes/NewResource.ts index 93b36ad22d..197de4acc2 100644 --- a/src/clients/routes/NewResource.ts +++ b/src/clients/routes/NewResource.ts @@ -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", diff --git a/src/clients/routes/NewScope.ts b/src/clients/routes/NewScope.ts new file mode 100644 index 0000000000..3a8098084c --- /dev/null +++ b/src/clients/routes/NewScope.ts @@ -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), +}); diff --git a/src/clients/routes/Resource.ts b/src/clients/routes/Resource.ts index 761048e3b0..bdf7660570 100644 --- a/src/clients/routes/Resource.ts +++ b/src/clients/routes/Resource.ts @@ -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", diff --git a/src/clients/routes/Scope.ts b/src/clients/routes/Scope.ts new file mode 100644 index 0000000000..7f08958c93 --- /dev/null +++ b/src/clients/routes/Scope.ts @@ -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), +});