diff --git a/package-lock.json b/package-lock.json index 48975d1fe0..52ae609e04 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,7 +7,7 @@ "name": "keycloak-admin-ui", "license": "Apache", "dependencies": { - "@keycloak/keycloak-admin-client": "^18.0.0-dev.22", + "@keycloak/keycloak-admin-client": "^18.0.1-dev.1", "@patternfly/patternfly": "^4.185.1", "@patternfly/react-code-editor": "^4.43.16", "@patternfly/react-core": "^4.202.16", @@ -3941,9 +3941,9 @@ } }, "node_modules/@keycloak/keycloak-admin-client": { - "version": "18.0.0-dev.22", - "resolved": "https://registry.npmjs.org/@keycloak/keycloak-admin-client/-/keycloak-admin-client-18.0.0-dev.22.tgz", - "integrity": "sha512-xWiZip5uzVoYkzJA3uJWqIKWy1krd9b9XgNzdhibw1uLbo9HGTzpdn8ViAXQrpgFxx5tgws0cTQrSduHHZsbFg==", + "version": "18.0.1-dev.1", + "resolved": "https://registry.npmjs.org/@keycloak/keycloak-admin-client/-/keycloak-admin-client-18.0.1-dev.1.tgz", + "integrity": "sha512-GYNtOkyiP2Xq18Hb6o3lGE/KQIBD2B4U3I6APU+18wwCnjN1pDEffZi1LN6U4BHjelH4HJlpFPEFFKdbT4el+A==", "dependencies": { "axios": "^0.26.1", "camelize-ts": "^1.0.8", @@ -26674,9 +26674,9 @@ } }, "@keycloak/keycloak-admin-client": { - "version": "18.0.0-dev.22", - "resolved": "https://registry.npmjs.org/@keycloak/keycloak-admin-client/-/keycloak-admin-client-18.0.0-dev.22.tgz", - "integrity": "sha512-xWiZip5uzVoYkzJA3uJWqIKWy1krd9b9XgNzdhibw1uLbo9HGTzpdn8ViAXQrpgFxx5tgws0cTQrSduHHZsbFg==", + "version": "18.0.1-dev.1", + "resolved": "https://registry.npmjs.org/@keycloak/keycloak-admin-client/-/keycloak-admin-client-18.0.1-dev.1.tgz", + "integrity": "sha512-GYNtOkyiP2Xq18Hb6o3lGE/KQIBD2B4U3I6APU+18wwCnjN1pDEffZi1LN6U4BHjelH4HJlpFPEFFKdbT4el+A==", "requires": { "axios": "^0.26.1", "camelize-ts": "^1.0.8", diff --git a/package.json b/package.json index 1fc5a87ae0..57a0ca6fc6 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "server:import-client": "./scripts/import-client.mjs" }, "dependencies": { - "@keycloak/keycloak-admin-client": "^18.0.0-dev.22", + "@keycloak/keycloak-admin-client": "^18.0.1-dev.1", "@patternfly/patternfly": "^4.185.1", "@patternfly/react-code-editor": "^4.43.16", "@patternfly/react-core": "^4.202.16", diff --git a/src/clients/ClientDetails.tsx b/src/clients/ClientDetails.tsx index c52c872a2c..fde3a4ba68 100644 --- a/src/clients/ClientDetails.tsx +++ b/src/clients/ClientDetails.tsx @@ -69,7 +69,7 @@ import { import { toClientScopesTab } from "./routes/ClientScopeTab"; import { AuthorizationExport } from "./authorization/AuthorizationExport"; import { useServerInfo } from "../context/server-info/ServerInfoProvider"; -import { PermissionsTab } from "./permissions/PermissionTab"; +import { PermissionsTab } from "../components/permission-tab/PermissionTab"; import { keyValueToArray } from "../components/key-value-form/key-value-convert"; type ClientDetailHeaderProps = { @@ -524,7 +524,9 @@ export default function ClientDetails() { {t("permissions")}} + title={ + {t("common:permissions")} + } {...authenticationRoute("permissions")} > @@ -565,10 +567,10 @@ export default function ClientDetails() { {t("permissions")}} + title={{t("common:permissions")}} {...route("permissions")} > - + )} { - const { t } = useTranslation("clients"); - const history = useHistory(); - const adminClient = useAdminClient(); - const { realm } = useRealm(); - const [realmId, setRealmId] = useState(""); - const [permission, setPermission] = useState(); - - useEffect(() => { - Promise.all([ - adminClient.clients.find({ - search: true, - clientId: realm, - }), - adminClient.clients.listFineGrainPermissions({ id: clientId }), - ]).then(([clients, permission]) => { - setRealmId(clients[0]?.id!); - setPermission(permission); - }); - }, []); - - const PermissionDetailLink = (permission: Record) => ( - - {permission.name} - - ); - - if (!permission) { - return ; - } - - return ( - - -
- - } - > - { - const p = await adminClient.clients.updateFineGrainPermission( - { id: clientId }, - { enabled } - ); - setPermission(p); - }} - /> - -
-
- ({ - id, - name, - }) - )} - ariaLabelKey="clients:permissions" - searchPlaceholderKey="clients:searchForPermission" - actionResolver={(rowData: IRowData) => { - const permission: Record = rowData.data; - return [ - { - title: t("common:edit"), - onClick() { - history.push( - toPermissionDetails({ - realm, - id: realmId, - permissionType: "scope", - permissionId: permission.id, - }) - ); - }, - }, - ]; - }} - columns={[ - { - name: "scopeName", - displayKey: "common:name", - cellRenderer: PermissionDetailLink, - }, - { - name: "description", - displayKey: "common:description", - cellRenderer: (permission: Record) => - t(`scopePermissions.${permission.name}-description`), - }, - ]} - /> -
- ); -}; diff --git a/src/clients/permissions/permissions-tab.css b/src/clients/permissions/permissions-tab.css deleted file mode 100644 index b9fbacc39c..0000000000 --- a/src/clients/permissions/permissions-tab.css +++ /dev/null @@ -1,4 +0,0 @@ - -.permission-label > .pf-c-form__group-label { - width: 120%; -} \ No newline at end of file diff --git a/src/common-messages.ts b/src/common-messages.ts index dbe94fbcd0..85e2be4aec 100644 --- a/src/common-messages.ts +++ b/src/common-messages.ts @@ -70,6 +70,7 @@ export default { retry: "Press here to refresh and continue", plus: "Plus", minus: "Minus", + confirm: "Confirm", clientScope: { default: "Default", @@ -89,6 +90,52 @@ export default { sessions: "Sessions", events: "Events", mappers: "Mappers", + permissions: "Permissions", + permissionsList: "Permission list", + permissionsListIntro: + "Edit the permission list by clicking the scope-name. It then redirects to the permission details page of the client named <1>{{realm}}", + usersPermissionsHint: + "Fine grained permissions for managing all users in realm. You can define different policies for who is allowed to manage users in the realm.", + clientsPermissionsHint: + "Fine grained permissions for administrators that want to manage this client or apply roles defined by this client.", + + permissionsScopeName: "Scope-name", + permissionsEnabled: "Permissions enabled", + permissionsDisable: "Disable permissions?", + permissionsDisableConfirm: + "If you disable the permissions, all the permissions in the list below will be delete automatically. In addition, the resources and scopes that are related will be removed", + scopePermissions: { + clients: { + "manage-description": + "Policies that decide if an administrator can manage this client", + "configure-description": + "Reduced management permissions for administrator. Cannot set scope, template, or protocol mappers.", + "view-description": + "Policies that decide if an administrator can view this client", + "map-roles-description": + "Policies that decide if an administrator can map roles defined by this client", + "map-roles-client-scope-description": + "Policies that decide if an administrator can apply roles defined by this client to the client scope of another client", + "map-roles-composite-description": + "Policies that decide if an administrator can apply roles defined by this client as a composite to another role", + "token-exchange-description": + "Policies that decide which clients are allowed exchange tokens for a token that is targeted to this client.", + }, + users: { + "view-description": + "Policies that decide if an administrator can view all users in realm", + "manage-description": + "Policies that decide if an administrator can manage all users in the realm", + "map-roles-description": + "Policies that decide if administrator can map roles for all users", + "manage-group-membership-description": + "Policies that decide if an administrator can manage group membership for all users in the realm. This is used in conjunction with specific group policy", + "impersonate-description": + "Policies that decide if administrator can impersonate other users", + "user-impersonated-description": + "Policies that decide which users can be impersonated. These policies are applied to the user being impersonated.", + }, + }, configure: "Configure", realmSettings: "Realm settings", diff --git a/src/components/permission-tab/PermissionTab.tsx b/src/components/permission-tab/PermissionTab.tsx new file mode 100644 index 0000000000..37bd1eb104 --- /dev/null +++ b/src/components/permission-tab/PermissionTab.tsx @@ -0,0 +1,218 @@ +import React, { useState } from "react"; +import { Link, useHistory } from "react-router-dom"; +import { Trans, useTranslation } from "react-i18next"; +import { + Card, + CardBody, + CardTitle, + Form, + FormGroup, + PageSection, + Switch, +} from "@patternfly/react-core"; +import { + ActionsColumn, + TableComposable, + Tbody, + Td, + Th, + Thead, + Tr, +} from "@patternfly/react-table"; + +import type { ManagementPermissionReference } from "@keycloak/keycloak-admin-client/lib/defs/managementPermissionReference"; +import { useAdminClient, useFetch } from "../../context/auth/AdminClient"; +import { useRealm } from "../../context/realm-context/RealmContext"; +import { toPermissionDetails } from "../../clients/routes/PermissionDetails"; +import { KeycloakSpinner } from "../../components/keycloak-spinner/KeycloakSpinner"; +import { HelpItem } from "../../components/help-enabler/HelpItem"; +import { useWhoAmI } from "../../context/whoami/WhoAmI"; +import { useConfirmDialog } from "../confirm-dialog/ConfirmDialog"; + +import "./permissions-tab.css"; + +type PermissionScreenType = "clients" | "users"; + +type PermissionsTabProps = { + id?: string; + type: PermissionScreenType; +}; + +export const PermissionsTab = ({ id, type }: PermissionsTabProps) => { + const { t } = useTranslation("common"); + const history = useHistory(); + const adminClient = useAdminClient(); + const { realm } = useRealm(); + const { whoAmI } = useWhoAmI(); + const [realmId, setRealmId] = useState(""); + const [permission, setPermission] = useState(); + + const togglePermissionEnabled = (enabled: boolean) => { + switch (type) { + case "clients": + return adminClient.clients.updateFineGrainPermission( + { id: id! }, + { enabled } + ); + case "users": + return adminClient.realms.updateUsersManagementPermissions({ + realm, + enabled, + }); + } + }; + + useFetch( + () => + Promise.all([ + adminClient.clients.find({ + search: true, + clientId: realm, + }), + (() => { + switch (type) { + case "clients": + return adminClient.clients.listFineGrainPermissions({ id: id! }); + case "users": + return adminClient.realms.getUsersManagementPermissions({ + realm, + }); + } + })(), + ]), + ([clients, permission]) => { + setRealmId(clients[0]?.id!); + setPermission(permission); + }, + [] + ); + + const [toggleDisableDialog, DisableConfirm] = useConfirmDialog({ + titleKey: "common:permissionsDisable", + messageKey: "common:permissionsDisableConfirm", + continueButtonLabel: "common:confirm", + onConfirm: async () => { + const permission = await togglePermissionEnabled(false); + setPermission(permission); + }, + }); + + if (!permission) { + return ; + } + + return ( + + + + {t("permissions")} + + {t(`${type}PermissionsHint`)} +
+ + } + > + { + if (enabled) { + const permission = await togglePermissionEnabled(enabled); + setPermission(permission); + } else { + toggleDisableDialog(); + } + }} + /> + +
+
+
+ {permission.enabled && ( + <> + + {t("permissionsList")} + + + {" "} + {{ realm: `${realm}-realm` }}. + + + + + + + + + + {t("permissionsScopeName")} + + {t("description")} + + + + {Object.entries(permission.scopePermissions || {}) + .sort((a, b) => + a[0]!.localeCompare(b[0]!, whoAmI.getLocale()) + ) + .map(([name, id]) => ( + + + + {name} + + + + {t(`scopePermissions.${type}.${name}-description`)} + + + + + + ))} + + + + + + )} +
+ ); +}; diff --git a/src/components/permission-tab/permissions-tab.css b/src/components/permission-tab/permissions-tab.css new file mode 100644 index 0000000000..691bcbc71e --- /dev/null +++ b/src/components/permission-tab/permissions-tab.css @@ -0,0 +1,9 @@ + +.permission-label > .pf-c-form__group-label { + width: 120%; +} + +.keycloak__permission__permission-table.pf-c-card { + border-top: 0; + border-bottom: 0; +} \ No newline at end of file diff --git a/src/user/UsersSection.tsx b/src/user/UsersSection.tsx index 60168b2354..289e5432ec 100644 --- a/src/user/UsersSection.tsx +++ b/src/user/UsersSection.tsx @@ -9,6 +9,8 @@ import { KebabToggle, Label, PageSection, + Tab, + TabTitleText, Text, TextContent, TextInput, @@ -42,6 +44,12 @@ import { toUser } from "./routes/User"; import { toAddUser } from "./routes/AddUser"; import helpUrls from "../help-urls"; import { KeycloakSpinner } from "../components/keycloak-spinner/KeycloakSpinner"; +import { PermissionsTab } from "../components/permission-tab/PermissionTab"; +import { toUsers, UserTab } from "./routes/Users"; +import { + routableTab, + RoutableTabs, +} from "../components/routable-tabs/RoutableTabs"; import "./user-section.css"; @@ -277,6 +285,15 @@ export default function UsersSection() { return ; } + const route = (tab: UserTab) => + routableTab({ + to: toUsers({ + realm: realmName, + tab, + }), + history, + }); + return ( <> @@ -285,111 +302,137 @@ export default function UsersSection() { titleKey="users:title" subKey="users:usersExplain" helpUrl={helpUrls.usersUrl} + divider={false} /> - setSelectedRows([...rows])} - emptyState={ - !listUsers ? ( - <> - - - - - { - setSearchUser(value); - }} - onKeyDown={(e) => { - if (e.key === "Enter") { - refresh(); - } - }} - /> - - - - {toolbar} - - - - - {t("searchForUserDescription")} - - - - ) : ( - - ) - } - toolbarItem={toolbar} - actionResolver={(rowData: IRowData) => { - const user: UserRepresentation = rowData.data; - if (!user.access?.manage) return []; + + {t("userList")}} + {...route("list")} + > + setSelectedRows([...rows])} + emptyState={ + !listUsers ? ( + <> + + + + + { + setSearchUser(value); + }} + onKeyDown={(e) => { + if (e.key === "Enter") { + refresh(); + } + }} + /> + + + + {toolbar} + + + + + {t("searchForUserDescription")} + + + + ) : ( + + ) + } + toolbarItem={toolbar} + actionResolver={(rowData: IRowData) => { + const user: UserRepresentation = rowData.data; + if (!user.access?.manage) return []; - return [ - { - title: t("common:delete"), - onClick: () => { - setSelectedRows([user]); - toggleDeleteDialog(); + return [ + { + title: t("common:delete"), + onClick: () => { + setSelectedRows([user]); + toggleDeleteDialog(); + }, + }, + ]; + }} + columns={[ + { + name: "username", + displayKey: "users:username", + cellRenderer: UserDetailLink, }, - }, - ]; - }} - columns={[ - { - name: "username", - displayKey: "users:username", - cellRenderer: UserDetailLink, - }, - { - name: "email", - displayKey: "users:email", - cellRenderer: ValidatedEmail, - }, - { - name: "lastName", - displayKey: "users:lastName", - cellFormatters: [emptyFormatter()], - }, - { - name: "firstName", - displayKey: "users:firstName", - cellFormatters: [emptyFormatter()], - }, - { - name: "status", - displayKey: "users:status", - cellRenderer: StatusRow, - }, - ]} - /> + { + name: "email", + displayKey: "users:email", + cellRenderer: ValidatedEmail, + }, + { + name: "lastName", + displayKey: "users:lastName", + cellFormatters: [emptyFormatter()], + }, + { + name: "firstName", + displayKey: "users:firstName", + cellFormatters: [emptyFormatter()], + }, + { + name: "status", + displayKey: "users:status", + cellRenderer: StatusRow, + }, + ]} + /> + + {t("common:permissions")}} + {...route("permissions")} + > + + + ); diff --git a/src/user/messages.ts b/src/user/messages.ts index ea1de3ce25..cbfd8e45a4 100644 --- a/src/user/messages.ts +++ b/src/user/messages.ts @@ -2,6 +2,7 @@ export default { users: { title: "Users", usersExplain: "Placeholder for users explanation.", + userList: "User list", searchForUser: "Search user", startBySearchingAUser: "Start by searching for users", searchForUserDescription: diff --git a/src/user/routes.ts b/src/user/routes.ts index ea7c57aff2..452e4a13f7 100644 --- a/src/user/routes.ts +++ b/src/user/routes.ts @@ -3,6 +3,6 @@ import { AddUserRoute } from "./routes/AddUser"; import { UserRoute } from "./routes/User"; import { UsersRoute } from "./routes/Users"; -const routes: RouteDef[] = [UsersRoute, AddUserRoute, UserRoute]; +const routes: RouteDef[] = [AddUserRoute, UsersRoute, UserRoute]; export default routes; diff --git a/src/user/routes/Users.ts b/src/user/routes/Users.ts index 450b068e57..51fc1b1e87 100644 --- a/src/user/routes/Users.ts +++ b/src/user/routes/Users.ts @@ -3,10 +3,12 @@ import { lazy } from "react"; import { generatePath } from "react-router-dom"; import type { RouteDef } from "../../route-config"; -export type UsersParams = { realm: string }; +export type UserTab = "list" | "permissions"; + +export type UsersParams = { realm: string; tab?: UserTab }; export const UsersRoute: RouteDef = { - path: "/:realm/users", + path: "/:realm/users/:tab?", component: lazy(() => import("../UsersSection")), breadcrumb: (t) => t("users:title"), access: "query-users",