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:
parent
86cf4f8501
commit
a5f08a9202
9 changed files with 211 additions and 58 deletions
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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()],
|
||||
},
|
||||
{
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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"
|
||||
|
||||
|
||||
}
|
||||
|
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
Loading…
Reference in a new issue