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

View file

@ -3,7 +3,11 @@ import { AlertType, AlertPanel } from "./AlertPanel";
import { AlertVariant } from "@patternfly/react-core";
type AlertProps = {
addAlert: (message: string, variant?: AlertVariant) => void;
addAlert: (
message: string,
variant?: AlertVariant,
description?: string
) => void;
};
export const AlertContext = createContext<AlertProps>({
@ -28,9 +32,10 @@ export const AlertProvider = ({ children }: { children: ReactNode }) => {
const addAlert = (
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 (

View file

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

View file

@ -1,28 +1,36 @@
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 {
AlertVariant,
Button,
ButtonVariant,
Checkbox,
PageSection,
} from "@patternfly/react-core";
import { IFormatter, IFormatterValueType } from "@patternfly/react-table";
import RoleRepresentation from "keycloak-admin/lib/defs/roleRepresentation";
import { ListEmptyState } from "../components/list-empty-state/ListEmptyState";
import { KeycloakDataTable } from "../components/table-toolbar/KeycloakDataTable";
import { formattedLinkTableCell } from "../components/external-link/FormattedLink";
import { useAlerts } from "../components/alert/Alerts";
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 = {
additionalRoles: RoleRepresentation[];
addComposites: (newReps: RoleRepresentation[]) => void;
parentRole: RoleFormType;
onRemove: (newReps: RoleRepresentation[]) => void;
};
export const AssociatedRolesTab = ({
additionalRoles,
addComposites,
parentRole,
onRemove,
}: AssociatedRolesTabProps) => {
const { t } = useTranslation("roles");
const history = useHistory();
@ -30,7 +38,11 @@ export const AssociatedRolesTab = ({
const { url } = useRouteMatch();
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 () => {
return Promise.resolve(additionalRoles);
@ -40,33 +52,47 @@ export const AssociatedRolesTab = ({
tableRefresher.current && tableRefresher.current();
}, [additionalRoles]);
const RoleDetailLink = (role: RoleRepresentation) => (
<>
<Link key={role.id} to={`${url}/${role.id}`}>
{role.name}
</Link>
</>
);
const RoleName = (role: RoleRepresentation) => <>{role.name}</>;
const boolFormatter = (): IFormatter => (data?: IFormatterValueType) => {
const boolVal = data?.toString();
return (boolVal ? toUpperCase(boolVal) : undefined) as string;
};
const toggleModal = () => setOpen(!open);
const [toggleDeleteDialog, DeleteConfirm] = useConfirmDialog({
titleKey: "roles:roleRemoveAssociatedRoleConfirm",
messageKey: t("roles:roleRemoveAssociatedText", {
selectedRoleName: selectedRole ? selectedRole!.name : "",
messageKey: t("roles:roleRemoveAssociatedText"),
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",
continueButtonVariant: ButtonVariant.danger,
onConfirm: async () => {
try {
// await adminClient.roles.delCompositeRoles({ id: compID }, compies);
setSelectedRole(undefined);
addAlert(t("roleDeletedSuccess"), AlertVariant.success);
if (selectedRows.length === additionalRoles.length) {
onRemove(selectedRows);
const loc = url.replace(/\/AssociatedRoles/g, "/details");
history.push(loc);
}
onRemove(selectedRows);
await adminClient.roles.delCompositeRoles({ id }, selectedRows);
addAlert(t("associatedRolesRemoved"), AlertVariant.success);
} catch (error) {
addAlert(`${t("roleDeleteError")} ${error}`, AlertVariant.danger);
}
@ -82,23 +108,54 @@ export const AssociatedRolesTab = ({
<>
<PageSection variant="light">
<DeleteConfirm />
<DeleteAssociatedRolesConfirm />
<AssociatedRolesModal
onConfirm={addComposites}
existingCompositeRoles={additionalRoles}
open={open}
toggleDialog={() => setOpen(!open)}
/>
<KeycloakDataTable
key={selectedRole ? selectedRole.id : "roleList"}
loader={loader}
ariaLabelKey="roles:roleList"
searchPlaceholderKey="roles:searchFor"
canSelectAll
onSelect={(rows) => {
setSelectedRows([...rows]);
}}
isPaginated
setRefresher={setRefresher}
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={[
{
title: t("common:remove"),
onRowClick: (role) => {
setSelectedRole(role);
setSelectedRows([role]);
toggleDeleteDialog();
},
},
@ -107,12 +164,12 @@ export const AssociatedRolesTab = ({
{
name: "name",
displayKey: "roles:roleName",
cellRenderer: RoleDetailLink,
cellRenderer: RoleName,
cellFormatters: [formattedLinkTableCell(), emptyFormatter()],
},
{
name: "composite",
displayKey: "roles:composite",
name: "inherited from",
displayKey: "roles:inheritedFrom",
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);
return (
<>
<DeleteConfirm />
<DeleteAllAssociatedRolesConfirm />
<AssociatedRolesModal
onConfirm={addComposites}
existingCompositeRoles={additionalRoles}
@ -226,10 +254,30 @@ export const RealmRoleTabs = () => {
/>
<ViewHeader
titleKey={role?.name || t("createRole")}
badge={additionalRoles.length > 0 ? t("composite") : ""}
badgeId="composite-role-badge"
badgeIsRead={true}
subKey={id ? "" : "roles:roleCreateExplain"}
actionsDropdownId="roles-actions-dropdown"
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
key="delete-role"
@ -269,7 +317,12 @@ export const RealmRoleTabs = () => {
eventKey="AssociatedRoles"
title={<TabTitleText>{t("associatedRolesText")}</TabTitleText>}
>
<AssociatedRolesTab additionalRoles={additionalRoles} />
<AssociatedRolesTab
additionalRoles={additionalRoles}
addComposites={addComposites}
parentRole={role!}
onRemove={() => refresh()}
/>
</Tab>
) : null}
<Tab

View file

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

View file

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

View file

@ -8,6 +8,7 @@
"addAssociatedRolesSuccess": "Associated roles have been added",
"associatedRolesModalTitle": "Add roles to {{name}}",
"title": "Realm roles",
"addRole": "Add role",
"createRole": "Create role",
"importRole": "Import role",
"roleID": "Role ID",
@ -19,6 +20,7 @@
"composite": "Composite",
"deleteRole": "Delete this role",
"details": "Details",
"inheritedFrom": "Inherited from",
"roleList": "Role list",
"searchFor": "Search role by name",
"generalSettings": "General Settings",
@ -30,14 +32,21 @@
"roleDeleteConfirm": "Delete role?",
"roleDeleteConfirmDialog": "This action will permanently delete the role {{selectedRoleName}} and cannot be undone.",
"roleDeletedSuccess": "The role has been deleted",
"roleDeleteError": "Could not delete role:",
"roleDeleteError": "Could not delete role: {{error}}",
"roleSaveSuccess": "The role has been saved",
"roleSaveError": "Could not save role: {{error}}",
"noRolesInThisRealm": "No roles in this realm",
"noRolesInThisRealmInstructions": "You haven't created any roles in this realm. Create a role to get started.",
"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?",
"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);
});
it("Associated roles modal test", function () {
it("Associated roles test", function () {
itemId += "_" + (Math.random() + 1).toString(36).substring(7);
// Create
@ -79,6 +79,10 @@ describe("Realm roles test", function () {
cy.get('[type="checkbox"]').eq(1).check();
cy.get("#add-associated-roles-button").contains("Add").click();
cy.url().should("include", "/AssociatedRoles");
cy.get("#composite-role-badge").should("contain.text", "Composite");
});
});
});