Added user permission tab (#2501)

This commit is contained in:
Erik Jan de Wit 2022-04-26 12:11:17 +02:00 committed by GitHub
parent 97aaf98e49
commit eb3d3714fc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 434 additions and 269 deletions

14
package-lock.json generated
View file

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

View file

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

View file

@ -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() {
<Tab
id="permissions"
data-testid="authorizationPermissions"
title={<TabTitleText>{t("permissions")}</TabTitleText>}
title={
<TabTitleText>{t("common:permissions")}</TabTitleText>
}
{...authenticationRoute("permissions")}
>
<AuthorizationPermissions clientId={clientId} />
@ -565,10 +567,10 @@ export default function ClientDetails() {
<Tab
id="permissions"
data-testid="permissionsTab"
title={<TabTitleText>{t("permissions")}</TabTitleText>}
title={<TabTitleText>{t("common:permissions")}</TabTitleText>}
{...route("permissions")}
>
<PermissionsTab clientId={client.id!} />
<PermissionsTab id={client.id!} type="clients" />
</Tab>
)}
<Tab

View file

@ -167,7 +167,6 @@ export default {
resourceDeletedSuccess: "The resource successfully deleted",
resourceDeletedError: "Could not remove the resource {{error}}",
identityInformation: "Identity Information",
permissions: "Permissions",
searchForPermission: "Search for permission",
deleteScope: "Permanently delete authorization scope?",
deleteScopeConfirm:
@ -554,23 +553,5 @@ export default {
never: "Never expires",
},
mappers: "Mappers",
permissionsEnabled: "Permissions enabled",
scopePermissions: {
"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.",
},
},
};

View file

@ -1,134 +0,0 @@
import React, { useEffect, useState } from "react";
import { Link, useHistory } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Form, FormGroup, PageSection, Switch } from "@patternfly/react-core";
import type { IRowData } from "@patternfly/react-table";
import type { ManagementPermissionReference } from "@keycloak/keycloak-admin-client/lib/defs/managementPermissionReference";
import { KeycloakDataTable } from "../../components/table-toolbar/KeycloakDataTable";
import { useAdminClient } from "../../context/auth/AdminClient";
import { useRealm } from "../../context/realm-context/RealmContext";
import { toPermissionDetails } from "../routes/PermissionDetails";
import { KeycloakSpinner } from "../../components/keycloak-spinner/KeycloakSpinner";
import { HelpItem } from "../../components/help-enabler/HelpItem";
import "./permissions-tab.css";
type PermissionsTabProps = {
clientId: string;
};
export const PermissionsTab = ({ clientId }: PermissionsTabProps) => {
const { t } = useTranslation("clients");
const history = useHistory();
const adminClient = useAdminClient();
const { realm } = useRealm();
const [realmId, setRealmId] = useState("");
const [permission, setPermission] = useState<ManagementPermissionReference>();
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<string, string>) => (
<Link
key={permission.id}
to={toPermissionDetails({
realm,
id: realmId,
permissionType: "scope",
permissionId: permission.id,
})}
>
{permission.name}
</Link>
);
if (!permission) {
return <KeycloakSpinner />;
}
return (
<PageSection variant="light" className="pf-u-p-0">
<PageSection variant="light">
<Form isHorizontal>
<FormGroup
className="permission-label"
label={t("permissionsEnabled")}
fieldId="permissionsEnabled"
labelIcon={
<HelpItem
helpText="clients-help:permissionsEnabled"
fieldLabelId="clients:permissionsEnabled"
/>
}
>
<Switch
id="permissionsEnabled"
label={t("common:on")}
labelOff={t("common:off")}
isChecked={permission.enabled}
onChange={async (enabled) => {
const p = await adminClient.clients.updateFineGrainPermission(
{ id: clientId },
{ enabled }
);
setPermission(p);
}}
/>
</FormGroup>
</Form>
</PageSection>
<KeycloakDataTable
loader={Object.entries(permission.scopePermissions || {}).map(
([name, id]) => ({
id,
name,
})
)}
ariaLabelKey="clients:permissions"
searchPlaceholderKey="clients:searchForPermission"
actionResolver={(rowData: IRowData) => {
const permission: Record<string, string> = 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<string, string>) =>
t(`scopePermissions.${permission.name}-description`),
},
]}
/>
</PageSection>
);
};

View file

@ -1,4 +0,0 @@
.permission-label > .pf-c-form__group-label {
width: 120%;
}

View file

@ -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}}</1>",
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",

View file

@ -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<ManagementPermissionReference>();
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 <KeycloakSpinner />;
}
return (
<PageSection variant="light">
<DisableConfirm />
<Card isFlat>
<CardTitle>{t("permissions")}</CardTitle>
<CardBody>
{t(`${type}PermissionsHint`)}
<Form isHorizontal className="pf-u-pt-md">
<FormGroup
hasNoPaddingTop
className="permission-label"
label={t("permissionsEnabled")}
fieldId="permissionsEnabled"
labelIcon={
<HelpItem
helpText="clients-help:permissionsEnabled"
fieldLabelId="clients:permissionsEnabled"
/>
}
>
<Switch
id="permissionsEnabled"
label={t("common:on")}
labelOff={t("common:off")}
isChecked={permission.enabled}
onChange={async (enabled) => {
if (enabled) {
const permission = await togglePermissionEnabled(enabled);
setPermission(permission);
} else {
toggleDisableDialog();
}
}}
/>
</FormGroup>
</Form>
</CardBody>
</Card>
{permission.enabled && (
<>
<Card isFlat className="pf-u-mt-lg">
<CardTitle>{t("permissionsList")}</CardTitle>
<CardBody>
<Trans i18nKey="common:permissionsListIntro">
{" "}
<strong>{{ realm: `${realm}-realm` }}</strong>.
</Trans>
</CardBody>
</Card>
<Card isFlat className="keycloak__permission__permission-table">
<CardBody className="pf-u-p-0">
<TableComposable
aria-label={t("permissionsList")}
variant="compact"
>
<Thead>
<Tr>
<Th id="permissionsScopeName">
{t("permissionsScopeName")}
</Th>
<Th id="description">{t("description")}</Th>
</Tr>
</Thead>
<Tbody>
{Object.entries(permission.scopePermissions || {})
.sort((a, b) =>
a[0]!.localeCompare(b[0]!, whoAmI.getLocale())
)
.map(([name, id]) => (
<Tr key={id}>
<Td>
<Link
to={toPermissionDetails({
realm,
id: realmId,
permissionType: "scope",
permissionId: id,
})}
>
{name}
</Link>
</Td>
<Td>
{t(`scopePermissions.${type}.${name}-description`)}
</Td>
<Td isActionCell>
<ActionsColumn
items={[
{
title: t("common:edit"),
onClick() {
history.push(
toPermissionDetails({
realm,
id: realmId,
permissionType: "scope",
permissionId: id,
})
);
},
},
]}
/>
</Td>
</Tr>
))}
</Tbody>
</TableComposable>
</CardBody>
</Card>
</>
)}
</PageSection>
);
};

View file

@ -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;
}

View file

@ -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 <KeycloakSpinner />;
}
const route = (tab: UserTab) =>
routableTab({
to: toUsers({
realm: realmName,
tab,
}),
history,
});
return (
<>
<DeleteConfirm />
@ -285,111 +302,137 @@ export default function UsersSection() {
titleKey="users:title"
subKey="users:usersExplain"
helpUrl={helpUrls.usersUrl}
divider={false}
/>
<PageSection
data-testid="users-page"
variant="light"
className="pf-u-p-0"
>
<KeycloakDataTable
key={key}
loader={loader}
isPaginated
ariaLabelKey="users:title"
searchPlaceholderKey="users:searchForUser"
canSelectAll
onSelect={(rows) => setSelectedRows([...rows])}
emptyState={
!listUsers ? (
<>
<Toolbar>
<ToolbarContent>
<ToolbarItem>
<InputGroup>
<TextInput
name="search-input"
type="search"
aria-label={t("search")}
placeholder={t("users:searchForUser")}
onChange={(value) => {
setSearchUser(value);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
refresh();
}
}}
/>
<Button
variant={ButtonVariant.control}
aria-label={t("common:search")}
onClick={refresh}
>
<SearchIcon />
</Button>
</InputGroup>
</ToolbarItem>
{toolbar}
</ToolbarContent>
</Toolbar>
<EmptyState data-testid="empty-state" variant="large">
<TextContent className="kc-search-users-text">
<Text>{t("searchForUserDescription")}</Text>
</TextContent>
</EmptyState>
</>
) : (
<ListEmptyState
message={t("noUsersFound")}
instructions={t("emptyInstructions")}
primaryActionText={t("createNewUser")}
onPrimaryAction={goToCreate}
/>
)
}
toolbarItem={toolbar}
actionResolver={(rowData: IRowData) => {
const user: UserRepresentation = rowData.data;
if (!user.access?.manage) return [];
<RoutableTabs
data-testid="user-tabs"
defaultLocation={toUsers({
realm: realmName,
tab: "list",
})}
isBox
mountOnEnter
>
<Tab
id="list"
data-testid="listTab"
title={<TabTitleText>{t("userList")}</TabTitleText>}
{...route("list")}
>
<KeycloakDataTable
key={key}
loader={loader}
isPaginated
ariaLabelKey="users:title"
searchPlaceholderKey="users:searchForUser"
canSelectAll
onSelect={(rows) => setSelectedRows([...rows])}
emptyState={
!listUsers ? (
<>
<Toolbar>
<ToolbarContent>
<ToolbarItem>
<InputGroup>
<TextInput
name="search-input"
type="search"
aria-label={t("search")}
placeholder={t("users:searchForUser")}
onChange={(value) => {
setSearchUser(value);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
refresh();
}
}}
/>
<Button
variant={ButtonVariant.control}
aria-label={t("common:search")}
onClick={refresh}
>
<SearchIcon />
</Button>
</InputGroup>
</ToolbarItem>
{toolbar}
</ToolbarContent>
</Toolbar>
<EmptyState data-testid="empty-state" variant="large">
<TextContent className="kc-search-users-text">
<Text>{t("searchForUserDescription")}</Text>
</TextContent>
</EmptyState>
</>
) : (
<ListEmptyState
message={t("noUsersFound")}
instructions={t("emptyInstructions")}
primaryActionText={t("createNewUser")}
onPrimaryAction={goToCreate}
/>
)
}
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,
},
]}
/>
</Tab>
<Tab
id="permissions"
data-testid="permissionsTab"
title={<TabTitleText>{t("common:permissions")}</TabTitleText>}
{...route("permissions")}
>
<PermissionsTab type="users" />
</Tab>
</RoutableTabs>
</PageSection>
</>
);

View file

@ -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:

View file

@ -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;

View file

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