Realm roles: associated roles tab (#358)

* WIP  modal

* modal WIP

* add modal

* place modal in separate file

* format

* wip implementation

* getCompositeRoles with Jeff

* add associated roles tab WIP

* addComposites function WIP

* fix post call

* additional roles fetch

* big rebase

* WIP refresh

* resolve conflicts with Erik latest -> fixes role creation

* cypress tests, bump react-hook-form to remove console warnings

* delete add

* refresh with Jeff, update cypress tests, select additional roles tab on add

* make dropdownId optional

* format

* add additionalRolesModal to associated roles tab

* add toolbar items

* add toolbaritems to associated role tab, matches mock

* rebase

* add descriptions to alert

* add badge

* fix badge logic

* fix URL when associate roles are deleted, format

* update cypress test

* format

* add associated roles refresh, PR feedback from Erik

* add associated roles refresh, PR feedback from Erik

* lint
This commit is contained in:
Eugenia 2021-02-17 02:17:04 -05:00 committed by GitHub
parent 86cf4f8501
commit a5f08a9202
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 211 additions and 58 deletions

View file

@ -10,6 +10,7 @@ export type AlertType = {
key: number; key: number;
message: string; message: string;
variant: AlertVariant; variant: AlertVariant;
description?: string;
}; };
type AlertPanelProps = { type AlertPanelProps = {
@ -20,21 +21,24 @@ type AlertPanelProps = {
export function AlertPanel({ alerts, onCloseAlert }: AlertPanelProps) { export function AlertPanel({ alerts, onCloseAlert }: AlertPanelProps) {
return ( return (
<AlertGroup isToast> <AlertGroup isToast>
{alerts.map(({ key, variant, message }) => ( {alerts.map(({ key, variant, message, description }) => (
<Alert <>
key={key} <Alert
isLiveRegion key={key}
timeout={true} isLiveRegion
variant={AlertVariant[variant]} variant={AlertVariant[variant]}
variantLabel="" variantLabel=""
title={message} title={message}
actionClose={ actionClose={
<AlertActionCloseButton <AlertActionCloseButton
title={message} title={message}
onClose={() => onCloseAlert(key)} onClose={() => onCloseAlert(key)}
/> />
} }
/> >
{description && <p>{description}</p>}
</Alert>
</>
))} ))}
</AlertGroup> </AlertGroup>
); );

View file

@ -3,7 +3,11 @@ import { AlertType, AlertPanel } from "./AlertPanel";
import { AlertVariant } from "@patternfly/react-core"; import { AlertVariant } from "@patternfly/react-core";
type AlertProps = { type AlertProps = {
addAlert: (message: string, variant?: AlertVariant) => void; addAlert: (
message: string,
variant?: AlertVariant,
description?: string
) => void;
}; };
export const AlertContext = createContext<AlertProps>({ export const AlertContext = createContext<AlertProps>({
@ -28,9 +32,10 @@ export const AlertProvider = ({ children }: { children: ReactNode }) => {
const addAlert = ( const addAlert = (
message: string, message: string,
variant: AlertVariant = AlertVariant.default variant: AlertVariant = AlertVariant.default,
description?: string
) => { ) => {
setAlerts([...alerts, { key: createId(), message, variant }]); setAlerts([...alerts, { key: createId(), message, variant, description }]);
}; };
return ( return (

View file

@ -25,6 +25,8 @@ import {
export type ViewHeaderProps = { export type ViewHeaderProps = {
titleKey: string; titleKey: string;
badge?: string; badge?: string;
badgeId?: string;
badgeIsRead?: boolean;
subKey: string; subKey: string;
actionsDropdownId?: string; actionsDropdownId?: string;
subKeyLinkProps?: FormattedLinkProps; subKeyLinkProps?: FormattedLinkProps;
@ -39,6 +41,8 @@ export const ViewHeader = ({
actionsDropdownId, actionsDropdownId,
titleKey, titleKey,
badge, badge,
badgeId,
badgeIsRead,
subKey, subKey,
subKeyLinkProps, subKeyLinkProps,
dropdownItems, dropdownItems,
@ -73,7 +77,9 @@ export const ViewHeader = ({
</LevelItem> </LevelItem>
{badge && ( {badge && (
<LevelItem> <LevelItem>
<Badge>{badge}</Badge> <Badge id={badgeId} isRead={badgeIsRead}>
{badge}
</Badge>
</LevelItem> </LevelItem>
)} )}
</Level> </Level>

View file

@ -1,28 +1,36 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { Link, useHistory, useRouteMatch } from "react-router-dom"; import { useHistory, useParams, useRouteMatch } from "react-router-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
AlertVariant, AlertVariant,
Button, Button,
ButtonVariant, ButtonVariant,
Checkbox,
PageSection, PageSection,
} from "@patternfly/react-core"; } from "@patternfly/react-core";
import { IFormatter, IFormatterValueType } from "@patternfly/react-table";
import RoleRepresentation from "keycloak-admin/lib/defs/roleRepresentation"; import RoleRepresentation from "keycloak-admin/lib/defs/roleRepresentation";
import { ListEmptyState } from "../components/list-empty-state/ListEmptyState"; import { ListEmptyState } from "../components/list-empty-state/ListEmptyState";
import { KeycloakDataTable } from "../components/table-toolbar/KeycloakDataTable"; import { KeycloakDataTable } from "../components/table-toolbar/KeycloakDataTable";
import { formattedLinkTableCell } from "../components/external-link/FormattedLink"; import { formattedLinkTableCell } from "../components/external-link/FormattedLink";
import { useAlerts } from "../components/alert/Alerts"; import { useAlerts } from "../components/alert/Alerts";
import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog"; import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog";
import { emptyFormatter, toUpperCase } from "../util"; import { boolFormatter, emptyFormatter } from "../util";
import { AssociatedRolesModal } from "./AssociatedRolesModal";
import { useAdminClient } from "../context/auth/AdminClient";
import { RoleFormType } from "./RealmRoleTabs";
type AssociatedRolesTabProps = { type AssociatedRolesTabProps = {
additionalRoles: RoleRepresentation[]; additionalRoles: RoleRepresentation[];
addComposites: (newReps: RoleRepresentation[]) => void;
parentRole: RoleFormType;
onRemove: (newReps: RoleRepresentation[]) => void;
}; };
export const AssociatedRolesTab = ({ export const AssociatedRolesTab = ({
additionalRoles, additionalRoles,
addComposites,
parentRole,
onRemove,
}: AssociatedRolesTabProps) => { }: AssociatedRolesTabProps) => {
const { t } = useTranslation("roles"); const { t } = useTranslation("roles");
const history = useHistory(); const history = useHistory();
@ -30,7 +38,11 @@ export const AssociatedRolesTab = ({
const { url } = useRouteMatch(); const { url } = useRouteMatch();
const tableRefresher = React.useRef<() => void>(); const tableRefresher = React.useRef<() => void>();
const [selectedRole, setSelectedRole] = useState<RoleRepresentation>(); const [selectedRows, setSelectedRows] = useState<RoleRepresentation[]>([]);
const [open, setOpen] = useState(false);
const adminClient = useAdminClient();
const { id } = useParams<{ id: string; clientId: string }>();
const loader = async () => { const loader = async () => {
return Promise.resolve(additionalRoles); return Promise.resolve(additionalRoles);
@ -40,33 +52,47 @@ export const AssociatedRolesTab = ({
tableRefresher.current && tableRefresher.current(); tableRefresher.current && tableRefresher.current();
}, [additionalRoles]); }, [additionalRoles]);
const RoleDetailLink = (role: RoleRepresentation) => ( const RoleName = (role: RoleRepresentation) => <>{role.name}</>;
<>
<Link key={role.id} to={`${url}/${role.id}`}>
{role.name}
</Link>
</>
);
const boolFormatter = (): IFormatter => (data?: IFormatterValueType) => { const toggleModal = () => setOpen(!open);
const boolVal = data?.toString();
return (boolVal ? toUpperCase(boolVal) : undefined) as string;
};
const [toggleDeleteDialog, DeleteConfirm] = useConfirmDialog({ const [toggleDeleteDialog, DeleteConfirm] = useConfirmDialog({
titleKey: "roles:roleRemoveAssociatedRoleConfirm", titleKey: "roles:roleRemoveAssociatedRoleConfirm",
messageKey: t("roles:roleRemoveAssociatedText", { messageKey: t("roles:roleRemoveAssociatedText"),
selectedRoleName: selectedRole ? selectedRole!.name : "", continueButtonLabel: "common:delete",
continueButtonVariant: ButtonVariant.danger,
onConfirm: async () => {
try {
await adminClient.roles.delCompositeRoles({ id }, selectedRows);
setSelectedRows([]);
addAlert(t("associatedRolesRemoved"), AlertVariant.success);
} catch (error) {
addAlert(t("roleDeleteError", { error }), AlertVariant.danger);
}
},
});
const [
toggleDeleteAssociatedRolesDialog,
DeleteAssociatedRolesConfirm,
] = useConfirmDialog({
titleKey: t("roles:removeAssociatedRoles") + "?",
messageKey: t("roles:removeAllAssociatedRolesConfirmDialog", {
name: parentRole?.name || t("createRole"),
}), }),
continueButtonLabel: "common:delete", continueButtonLabel: "common:delete",
continueButtonVariant: ButtonVariant.danger, continueButtonVariant: ButtonVariant.danger,
onConfirm: async () => { onConfirm: async () => {
try { try {
// await adminClient.roles.delCompositeRoles({ id: compID }, compies); if (selectedRows.length === additionalRoles.length) {
onRemove(selectedRows);
setSelectedRole(undefined); const loc = url.replace(/\/AssociatedRoles/g, "/details");
addAlert(t("roleDeletedSuccess"), AlertVariant.success); history.push(loc);
}
onRemove(selectedRows);
await adminClient.roles.delCompositeRoles({ id }, selectedRows);
addAlert(t("associatedRolesRemoved"), AlertVariant.success);
} catch (error) { } catch (error) {
addAlert(`${t("roleDeleteError")} ${error}`, AlertVariant.danger); addAlert(`${t("roleDeleteError")} ${error}`, AlertVariant.danger);
} }
@ -82,23 +108,54 @@ export const AssociatedRolesTab = ({
<> <>
<PageSection variant="light"> <PageSection variant="light">
<DeleteConfirm /> <DeleteConfirm />
<DeleteAssociatedRolesConfirm />
<AssociatedRolesModal
onConfirm={addComposites}
existingCompositeRoles={additionalRoles}
open={open}
toggleDialog={() => setOpen(!open)}
/>
<KeycloakDataTable <KeycloakDataTable
key={selectedRole ? selectedRole.id : "roleList"}
loader={loader} loader={loader}
ariaLabelKey="roles:roleList" ariaLabelKey="roles:roleList"
searchPlaceholderKey="roles:searchFor" searchPlaceholderKey="roles:searchFor"
canSelectAll
onSelect={(rows) => {
setSelectedRows([...rows]);
}}
isPaginated isPaginated
setRefresher={setRefresher} setRefresher={setRefresher}
toolbarItem={ toolbarItem={
<> <>
<Button onClick={goToCreate}>{t("createRole")}</Button> <Checkbox
label="Hide inherited roles"
key="associated-roles-check"
id="kc-hide-inherited-roles-checkbox"
/>
<Button
className="kc-add-role-button"
key="add-role-button"
onClick={() => toggleModal()}
>
{t("addRole")}
</Button>
<Button
variant="link"
isDisabled={selectedRows.length == 0}
key="remove-role-button"
onClick={() => {
toggleDeleteAssociatedRolesDialog();
}}
>
{t("removeRoles")}
</Button>
</> </>
} }
actions={[ actions={[
{ {
title: t("common:remove"), title: t("common:remove"),
onRowClick: (role) => { onRowClick: (role) => {
setSelectedRole(role); setSelectedRows([role]);
toggleDeleteDialog(); toggleDeleteDialog();
}, },
}, },
@ -107,12 +164,12 @@ export const AssociatedRolesTab = ({
{ {
name: "name", name: "name",
displayKey: "roles:roleName", displayKey: "roles:roleName",
cellRenderer: RoleDetailLink, cellRenderer: RoleName,
cellFormatters: [formattedLinkTableCell(), emptyFormatter()], cellFormatters: [formattedLinkTableCell(), emptyFormatter()],
}, },
{ {
name: "composite", name: "inherited from",
displayKey: "roles:composite", displayKey: "roles:inheritedFrom",
cellFormatters: [boolFormatter(), emptyFormatter()], cellFormatters: [boolFormatter(), emptyFormatter()],
}, },
{ {

View file

@ -213,11 +213,39 @@ export const RealmRoleTabs = () => {
}, },
}); });
const [
toggleDeleteAllAssociatedRolesDialog,
DeleteAllAssociatedRolesConfirm,
] = useConfirmDialog({
titleKey: t("roles:removeAllAssociatedRoles") + "?",
messageKey: t("roles:removeAllAssociatedRolesConfirmDialog", {
name: role?.name || t("createRole"),
}),
continueButtonLabel: "common:delete",
continueButtonVariant: ButtonVariant.danger,
onConfirm: async () => {
try {
await adminClient.roles.delCompositeRoles({ id }, additionalRoles);
addAlert(
t("compositeRoleOff"),
AlertVariant.success,
t("compositesRemovedAlertDescription")
);
const loc = url.replace(/\/AssociatedRoles/g, "/details");
history.push(loc);
refresh();
} catch (error) {
addAlert(`${t("roleDeleteError")} ${error}`, AlertVariant.danger);
}
},
});
const toggleModal = () => setOpen(!open); const toggleModal = () => setOpen(!open);
return ( return (
<> <>
<DeleteConfirm /> <DeleteConfirm />
<DeleteAllAssociatedRolesConfirm />
<AssociatedRolesModal <AssociatedRolesModal
onConfirm={addComposites} onConfirm={addComposites}
existingCompositeRoles={additionalRoles} existingCompositeRoles={additionalRoles}
@ -226,10 +254,30 @@ export const RealmRoleTabs = () => {
/> />
<ViewHeader <ViewHeader
titleKey={role?.name || t("createRole")} titleKey={role?.name || t("createRole")}
badge={additionalRoles.length > 0 ? t("composite") : ""}
badgeId="composite-role-badge"
badgeIsRead={true}
subKey={id ? "" : "roles:roleCreateExplain"} subKey={id ? "" : "roles:roleCreateExplain"}
actionsDropdownId="roles-actions-dropdown" actionsDropdownId="roles-actions-dropdown"
dropdownItems={ dropdownItems={
id url.includes("AssociatedRoles")
? [
<DropdownItem
key="delete-all-associated"
component="button"
onClick={() => toggleDeleteAllAssociatedRolesDialog()}
>
{t("roles:removeAllAssociatedRoles")}
</DropdownItem>,
<DropdownItem
key="delete-role"
component="button"
onClick={() => toggleDeleteDialog()}
>
{t("deleteRole")}
</DropdownItem>,
]
: id
? [ ? [
<DropdownItem <DropdownItem
key="delete-role" key="delete-role"
@ -269,7 +317,12 @@ export const RealmRoleTabs = () => {
eventKey="AssociatedRoles" eventKey="AssociatedRoles"
title={<TabTitleText>{t("associatedRolesText")}</TabTitleText>} title={<TabTitleText>{t("associatedRolesText")}</TabTitleText>}
> >
<AssociatedRolesTab additionalRoles={additionalRoles} /> <AssociatedRolesTab
additionalRoles={additionalRoles}
addComposites={addComposites}
parentRole={role!}
onRemove={() => refresh()}
/>
</Tab> </Tab>
) : null} ) : null}
<Tab <Tab

View file

@ -15,6 +15,10 @@
color: var(--pf-c-button--m-plain--Color); color: var(--pf-c-button--m-plain--Color);
} }
.kc-add-role-button {
margin-left: var(--pf-global--spacer--lg);
}
.kc-role-attributes__action-group { .kc-role-attributes__action-group {
/* subtract the padding at the bottom of the table from the action group margin */ /* subtract the padding at the bottom of the table from the action group margin */
--pf-c-form__group--m-action--MarginTop: calc( --pf-c-form__group--m-action--MarginTop: calc(

View file

@ -19,9 +19,14 @@ type RolesListProps = {
search?: string search?: string
) => Promise<RoleRepresentation[]>; ) => Promise<RoleRepresentation[]>;
paginated?: boolean; paginated?: boolean;
parentRoleId?: string;
}; };
export const RolesList = ({ loader, paginated = true }: RolesListProps) => { export const RolesList = ({
loader,
paginated = true,
parentRoleId,
}: RolesListProps) => {
const { t } = useTranslation("roles"); const { t } = useTranslation("roles");
const history = useHistory(); const history = useHistory();
const adminClient = useAdminClient(); const adminClient = useAdminClient();
@ -47,9 +52,15 @@ export const RolesList = ({ loader, paginated = true }: RolesListProps) => {
continueButtonVariant: ButtonVariant.danger, continueButtonVariant: ButtonVariant.danger,
onConfirm: async () => { onConfirm: async () => {
try { try {
await adminClient.roles.delById({ if (!parentRoleId) {
id: selectedRole!.id!, await adminClient.roles.delById({
}); id: selectedRole!.id!,
});
} else {
await adminClient.roles.delCompositeRoles({ id: parentRoleId }, [
selectedRole!,
]);
}
setSelectedRole(undefined); setSelectedRole(undefined);
addAlert(t("roleDeletedSuccess"), AlertVariant.success); addAlert(t("roleDeletedSuccess"), AlertVariant.success);
} catch (error) { } catch (error) {

View file

@ -8,6 +8,7 @@
"addAssociatedRolesSuccess": "Associated roles have been added", "addAssociatedRolesSuccess": "Associated roles have been added",
"associatedRolesModalTitle": "Add roles to {{name}}", "associatedRolesModalTitle": "Add roles to {{name}}",
"title": "Realm roles", "title": "Realm roles",
"addRole": "Add role",
"createRole": "Create role", "createRole": "Create role",
"importRole": "Import role", "importRole": "Import role",
"roleID": "Role ID", "roleID": "Role ID",
@ -19,6 +20,7 @@
"composite": "Composite", "composite": "Composite",
"deleteRole": "Delete this role", "deleteRole": "Delete this role",
"details": "Details", "details": "Details",
"inheritedFrom": "Inherited from",
"roleList": "Role list", "roleList": "Role list",
"searchFor": "Search role by name", "searchFor": "Search role by name",
"generalSettings": "General Settings", "generalSettings": "General Settings",
@ -30,14 +32,21 @@
"roleDeleteConfirm": "Delete role?", "roleDeleteConfirm": "Delete role?",
"roleDeleteConfirmDialog": "This action will permanently delete the role {{selectedRoleName}} and cannot be undone.", "roleDeleteConfirmDialog": "This action will permanently delete the role {{selectedRoleName}} and cannot be undone.",
"roleDeletedSuccess": "The role has been deleted", "roleDeletedSuccess": "The role has been deleted",
"roleDeleteError": "Could not delete role:", "roleDeleteError": "Could not delete role: {{error}}",
"roleSaveSuccess": "The role has been saved", "roleSaveSuccess": "The role has been saved",
"roleSaveError": "Could not save role: {{error}}", "roleSaveError": "Could not save role: {{error}}",
"noRolesInThisRealm": "No roles in this realm", "noRolesInThisRealm": "No roles in this realm",
"noRolesInThisRealmInstructions": "You haven't created any roles in this realm. Create a role to get started.", "noRolesInThisRealmInstructions": "You haven't created any roles in this realm. Create a role to get started.",
"roleAuthentication": "Role authentication", "roleAuthentication": "Role authentication",
"removeAllAssociatedRoles": "Remove all associated roles",
"removeAssociatedRoles": "Remove associated roles",
"removeRoles": "Remove roles",
"removeAllAssociatedRolesConfirmDialog": "This action will remove the associated roles of {{name}}. Users who have permission to {{name}} will no longer have access to these roles.",
"roleRemoveAssociatedRoleConfirm": "Remove associated role?", "roleRemoveAssociatedRoleConfirm": "Remove associated role?",
"roleRemoveAssociatedText": "This action will remove {{role}} from {{roleName}. All the associated roles of {{role}} will also be removed." "roleRemoveAssociatedText": "This action will remove {{role}} from {{roleName}. All the associated roles of {{role}} will also be removed.",
"compositeRoleOff": "Composite role turned off",
"associatedRolesRemoved": "Associated roles have been removed",
"compositesRemovedAlertDescription": "All the associated roles have been removed"
} }

View file

@ -60,7 +60,7 @@ describe("Realm roles test", function () {
listingPage.itemExist(itemId, false); listingPage.itemExist(itemId, false);
}); });
it("Associated roles modal test", function () { it("Associated roles test", function () {
itemId += "_" + (Math.random() + 1).toString(36).substring(7); itemId += "_" + (Math.random() + 1).toString(36).substring(7);
// Create // Create
@ -79,6 +79,10 @@ describe("Realm roles test", function () {
cy.get('[type="checkbox"]').eq(1).check(); cy.get('[type="checkbox"]').eq(1).check();
cy.get("#add-associated-roles-button").contains("Add").click(); cy.get("#add-associated-roles-button").contains("Add").click();
cy.url().should("include", "/AssociatedRoles");
cy.get("#composite-role-badge").should("contain.text", "Composite");
}); });
}); });
}); });