diff --git a/package.json b/package.json index fa4ebee027..ad0fea1596 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "moment": "^2.29.1", "react": "^16.8.5", "react-dom": "^16.8.5", - "react-hook-form": "^6.8.2", + "react-hook-form": "^6.8.3", "react-i18next": "^11.7.0", "react-router-dom": "^5.2.0", "use-react-router-breadcrumbs": "^1.0.5" diff --git a/src/components/form-access/__tests__/__snapshots__/FormAccess.test.tsx.snap b/src/components/form-access/__tests__/__snapshots__/FormAccess.test.tsx.snap index 55821eba20..9debc1f2b2 100644 --- a/src/components/form-access/__tests__/__snapshots__/FormAccess.test.tsx.snap +++ b/src/components/form-access/__tests__/__snapshots__/FormAccess.test.tsx.snap @@ -90,11 +90,14 @@ exports[` render normal form 1`] = ` "fieldArrayNamesRef": Object { "current": Set {}, }, + "fieldArrayValuesRef": Object { + "current": Object {}, + }, "fieldsRef": Object { "current": Object { "consentRequired": Object { "ref": Object { - "focus": undefined, + "focus": [Function], "name": "consentRequired", }, }, @@ -113,6 +116,18 @@ exports[` render normal form 1`] = ` "fieldsWithValidationRef": Object { "current": Object {}, }, + "formState": Object { + "dirtyFields": Object {}, + "errors": Object {}, + "isDirty": false, + "isSubmitSuccessful": false, + "isSubmitted": false, + "isSubmitting": false, + "isValid": false, + "isValidating": false, + "submitCount": 0, + "touched": Object {}, + }, "formStateRef": Object { "current": Object { "dirtyFields": Object {}, @@ -122,14 +137,13 @@ exports[` render normal form 1`] = ` "isSubmitted": false, "isSubmitting": false, "isValid": false, + "isValidating": false, "submitCount": 0, "touched": Object {}, }, }, "getValues": [Function], - "isWatchAllRef": Object { - "current": false, - }, + "isFormDirty": [Function], "mode": Object { "isOnAll": false, "isOnBlur": false, @@ -143,16 +157,21 @@ exports[` render normal form 1`] = ` }, "readFormStateRef": Object { "current": Object { - "dirtyFields": false, - "isDirty": false, - "isSubmitting": false, - "isValid": false, - "touched": false, + "constructor": true, + "dirtyFields": true, + "errors": true, + "isDirty": true, + "isSubmitSuccessful": true, + "isSubmitted": true, + "isSubmitting": true, + "isValid": true, + "isValidating": true, + "submitCount": true, + "touched": true, }, }, "register": [Function], "removeFieldEventListener": [Function], - "renderWatchedInputs": [Function], "resetFieldArrayFunctionRef": Object { "current": Object {}, }, @@ -164,6 +183,7 @@ exports[` render normal form 1`] = ` "trigger": [Function], "unregister": [Function], "updateFormState": [Function], + "updateWatchedValue": [Function], "useWatchFieldsRef": Object { "current": Object {}, }, @@ -174,9 +194,6 @@ exports[` render normal form 1`] = ` "current": Object {}, }, "validateResolver": undefined, - "watchFieldsRef": Object { - "current": Set {}, - }, "watchInternal": [Function], } } diff --git a/src/components/table-toolbar/KeycloakDataTable.tsx b/src/components/table-toolbar/KeycloakDataTable.tsx index 5c162a5cd0..6b8a8b803b 100644 --- a/src/components/table-toolbar/KeycloakDataTable.tsx +++ b/src/components/table-toolbar/KeycloakDataTable.tsx @@ -84,6 +84,7 @@ export type DataListProps = { isPaginated?: boolean; ariaLabelKey: string; searchPlaceholderKey: string; + setRefresher?: (refresher: () => void) => void; columns: Field[]; actions?: Action[]; actionResolver?: IActionsResolver; @@ -118,6 +119,7 @@ export function KeycloakDataTable({ searchPlaceholderKey, isPaginated = false, onSelect, + setRefresher, canSelectAll = false, loader, columns, @@ -138,6 +140,10 @@ export function KeycloakDataTable({ const [key, setKey] = useState(0); const refresh = () => setKey(new Date().getTime()); + useEffect(() => { + setRefresher && setRefresher(refresh); + }, []); + useEffect(() => { return asyncStateFetch( async () => { diff --git a/src/components/view-header/ViewHeader.tsx b/src/components/view-header/ViewHeader.tsx index 7a7a54725d..5d6aa4e78a 100644 --- a/src/components/view-header/ViewHeader.tsx +++ b/src/components/view-header/ViewHeader.tsx @@ -24,6 +24,7 @@ export type ViewHeaderProps = { titleKey: string; badge?: string; subKey: string; + actionsDropdownId?: string; subKeyLinkProps?: ButtonProps; dropdownItems?: ReactElement[]; lowerDropdownItems?: any; @@ -33,6 +34,7 @@ export type ViewHeaderProps = { }; export const ViewHeader = ({ + actionsDropdownId, titleKey, badge, subKey, @@ -99,7 +101,10 @@ export const ViewHeader = ({ + {t("common:action")} } diff --git a/src/realm-roles/AssociatedRolesModal.tsx b/src/realm-roles/AssociatedRolesModal.tsx index 403a89d192..b520718b8c 100644 --- a/src/realm-roles/AssociatedRolesModal.tsx +++ b/src/realm-roles/AssociatedRolesModal.tsx @@ -12,6 +12,8 @@ import { boolFormatter } from "../util"; export type AssociatedRolesModalProps = { open: boolean; toggleDialog: () => void; + onConfirm: (newReps: RoleRepresentation[]) => void; + existingCompositeRoles: RoleRepresentation[]; }; const attributesToArray = (attributes: { [key: string]: string }): any => { @@ -40,9 +42,17 @@ export const AssociatedRolesModal = (props: AssociatedRolesModalProps) => { const loader = async () => { const allRoles = await adminClient.roles.find(); - const roles = allRoles.filter((x) => x.name != name); + const existingAdditionalRoles = await adminClient.roles.getCompositeRoles({ + id, + }); - return roles; + return allRoles.filter((role: RoleRepresentation) => { + return ( + existingAdditionalRoles.find( + (existing: RoleRepresentation) => existing.name === role.name + ) === undefined && role.name !== name + ); + }); }; useEffect(() => { @@ -76,10 +86,12 @@ export const AssociatedRolesModal = (props: AssociatedRolesModalProps) => { actions={[ + + } + actions={[ + { + title: t("common:remove"), + onRowClick: (role) => { + setSelectedRole(role); + toggleDeleteDialog(); + }, + }, + ]} + columns={[ + { + name: "name", + displayKey: "roles:roleName", + cellRenderer: RoleDetailLink, + cellFormatters: [formattedLinkTableCell(), emptyFormatter()], + }, + { + name: "composite", + displayKey: "roles:composite", + cellFormatters: [boolFormatter(), emptyFormatter()], + }, + { + name: "description", + displayKey: "common:description", + cellFormatters: [emptyFormatter()], + }, + ]} + emptyState={ + + } + /> + + + ); +}; diff --git a/src/realm-roles/RealmRoleTabs.tsx b/src/realm-roles/RealmRoleTabs.tsx index 857a642eec..cf26c9cc7c 100644 --- a/src/realm-roles/RealmRoleTabs.tsx +++ b/src/realm-roles/RealmRoleTabs.tsx @@ -14,12 +14,15 @@ import { useFieldArray, useForm } from "react-hook-form"; import { useAlerts } from "../components/alert/Alerts"; import { useAdminClient } from "../context/auth/AdminClient"; import RoleRepresentation from "keycloak-admin/lib/defs/roleRepresentation"; +import Composites from "keycloak-admin/lib/defs/roleRepresentation"; import { KeyValueType, RoleAttributes } from "./RoleAttributes"; import { ViewHeader } from "../components/view-header/ViewHeader"; import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog"; import { RealmRoleForm } from "./RealmRoleForm"; +import { useRealm } from "../context/realm-context/RealmContext"; import { AssociatedRolesModal } from "./AssociatedRolesModal"; import { KeycloakTabs } from "../components/keycloak-tabs/KeycloakTabs"; +import { AssociatedRolesTab } from "./AssociatedRolesTab"; const arrayToAttributes = (attributeArray: KeyValueType[]) => { const initValue: { [index: string]: string[] } = {}; @@ -49,11 +52,25 @@ export const RealmRoleTabs = () => { const { t } = useTranslation("roles"); const form = useForm({ mode: "onChange" }); const history = useHistory(); + // const [name, setName] = useState(""); + const adminClient = useAdminClient(); const [role, setRole] = useState(); const { id, clientId } = useParams<{ id: string; clientId: string }>(); const { url } = useRouteMatch(); + + const { realm } = useRealm(); + + const [key, setKey] = useState(""); + + const refresh = () => { + setKey(`${new Date().getTime()}`); + }; + + const [additionalRoles, setAdditionalRoles] = useState( + [] + ); const { addAlert } = useAlerts(); const [open, setOpen] = useState(false); @@ -66,17 +83,23 @@ export const RealmRoleTabs = () => { }; useEffect(() => { - (async () => { + const update = async () => { if (id) { const fetchedRole = await adminClient.roles.findOneById({ id }); + const allAdditionalRoles = await adminClient.roles.getCompositeRoles({ + id, + }); + setAdditionalRoles(allAdditionalRoles); + const convertedRole = convert(fetchedRole); Object.entries(convertedRole).map((entry) => { form.setValue(entry[0], entry[1]); }); setRole(convertedRole); } - })(); - }, []); + }; + setTimeout(update, 100); + }, [key]); const { fields, append, remove } = useFieldArray({ control: form.control, @@ -101,6 +124,12 @@ export const RealmRoleTabs = () => { roleRepresentation ); } + + await adminClient.roles.createComposite( + { roleId: id, realm }, + additionalRoles + ); + setRole(role); } else { let createdRole; @@ -126,7 +155,9 @@ export const RealmRoleTabs = () => { }); } setRole(convert(createdRole)); - history.push(url.substr(0, url.lastIndexOf("/") + 1) + createdRole.id); + history.push( + url.substr(0, url.lastIndexOf("/") + 1) + createdRole.id + "/details" + ); } addAlert(t(id ? "roleSaveSuccess" : "roleCreated"), AlertVariant.success); } catch (error) { @@ -139,6 +170,23 @@ export const RealmRoleTabs = () => { } }; + const addComposites = async (composites: Composites[]): Promise => { + const compositeArray = composites; + setAdditionalRoles([...additionalRoles, ...compositeArray]); + + try { + await adminClient.roles.createComposite( + { roleId: id, realm: realm }, + compositeArray + ); + history.push(url.substr(0, url.lastIndexOf("/") + 1) + "AssociatedRoles"); + refresh(); + addAlert(t("addAssociatedRolesSuccess"), AlertVariant.success); + } catch (error) { + addAlert(t("addAssociatedRolesError", { error }), AlertVariant.danger); + } + }; + const [toggleDeleteDialog, DeleteConfirm] = useConfirmDialog({ titleKey: "roles:roleDeleteConfirm", messageKey: t("roles:roleDeleteConfirmDialog", { @@ -151,7 +199,10 @@ export const RealmRoleTabs = () => { if (!clientId) { await adminClient.roles.delById({ id }); } else { - await adminClient.clients.delRole({ id: clientId, roleName: name }); + await adminClient.clients.delRole({ + id: clientId, + roleName: role!.name as string, + }); } addAlert(t("roleDeletedSuccess"), AlertVariant.success); const loc = url.replace(/\/attributes/g, ""); @@ -167,10 +218,16 @@ export const RealmRoleTabs = () => { return ( <> - setOpen(!open)} /> + setOpen(!open)} + /> { , toggleModal()} > @@ -206,6 +264,14 @@ export const RealmRoleTabs = () => { editMode={true} /> + {additionalRoles.length > 0 ? ( + {t("associatedRolesText")}} + > + + + ) : null} {t("attributes")}} diff --git a/src/realm-roles/RoleAttributes.tsx b/src/realm-roles/RoleAttributes.tsx index 226e39ff6f..ede17adfc9 100644 --- a/src/realm-roles/RoleAttributes.tsx +++ b/src/realm-roles/RoleAttributes.tsx @@ -42,7 +42,7 @@ export const RoleAttributes = ({ const { t } = useTranslation("roles"); const columns = ["Key", "Value"]; - const watchFirstKey = watch("attributes[0].key"); + const watchFirstKey = watch("attributes[0].key", ""); return ( <> diff --git a/src/realm-roles/RolesList.tsx b/src/realm-roles/RolesList.tsx index 0769b0cb83..0a71ca483e 100644 --- a/src/realm-roles/RolesList.tsx +++ b/src/realm-roles/RolesList.tsx @@ -32,7 +32,7 @@ export const RolesList = ({ loader, paginated = true }: RolesListProps) => { const RoleDetailLink = (role: RoleRepresentation) => ( <> - + {role.name} diff --git a/src/realm-roles/messages.json b/src/realm-roles/messages.json index 6a6b61caa0..a9cb6a7d15 100644 --- a/src/realm-roles/messages.json +++ b/src/realm-roles/messages.json @@ -3,6 +3,7 @@ "attributes": "Attributes", "addAttributeText": "Add an attribute", "deleteAttributeText": "Delete an attribute", + "associatedRolesText": "Associated roles", "addAssociatedRolesText": "Add associated roles", "addAssociatedRolesSuccess": "Associated roles have been added", "associatedRolesModalTitle": "Add roles to {{name}}", @@ -19,7 +20,7 @@ "deleteRole": "Delete this role", "details": "Details", "roleList": "Role list", - "searchFor": "Search for role", + "searchFor": "Search role by name", "generalSettings": "General Settings", "capabilityConfig": "Capability config", "roleImportError": "Could not import role", @@ -27,13 +28,17 @@ "roleCreateError": "Could not create role: {{error}}", "roleImportSuccess": "Role import successful", "roleDeleteConfirm": "Delete role?", - "roleDeleteConfirmDialog": "This action will permanently delete the role {{name}} and cannot be undone.", + "roleDeleteConfirmDialog": "This action will permanently delete the role {{selectedRoleName}} and cannot be undone.", "roleDeletedSuccess": "The role has been deleted", "roleDeleteError": "Could not delete role:", "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" + "roleAuthentication": "Role authentication", + "roleRemoveAssociatedRoleConfirm": "Remove associated role?", + "roleRemoveAssociatedText": "This action will remove {{role}} from {{roleName}. All the associated roles of {{role}} will also be removed." + + } } diff --git a/tests/cypress/integration/realm_roles_test.spec.ts b/tests/cypress/integration/realm_roles_test.spec.ts index 2a772fdae8..c010852bcf 100644 --- a/tests/cypress/integration/realm_roles_test.spec.ts +++ b/tests/cypress/integration/realm_roles_test.spec.ts @@ -14,7 +14,6 @@ const listingPage = new ListingPage(); const createRealmRolePage = new CreateRealmRolePage(); describe("Realm roles test", function () { - describe("Realm roles creation", function () { beforeEach(function () { cy.visit(""); @@ -60,5 +59,26 @@ describe("Realm roles test", function () { listingPage.itemExist(itemId, false); }); + + it("Associated roles modal test", function () { + itemId += "_" + (Math.random() + 1).toString(36).substring(7); + + // Create + listingPage.itemExist(itemId, false).goToCreateItem(); + + createRealmRolePage.fillRealmRoleData(itemId).save(); + + masthead.checkNotificationMessage("Role created"); + + cy.get("#roles-actions-dropdown").last().click(); + + cy.get("#add-roles").click(); + + cy.wait(100); + + cy.get('[type="checkbox"]').eq(1).check(); + + cy.get("#add-associated-roles-button").contains("Add").click(); + }); }); }); diff --git a/yarn.lock b/yarn.lock index a764f24961..03fd24e0a5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -16908,10 +16908,10 @@ react-helmet-async@^1.0.2: react-fast-compare "^3.0.1" shallowequal "^1.1.0" -react-hook-form@^6.8.2: - version "6.8.2" - resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-6.8.2.tgz#2a63b3d8578bd4f7965767ae4e205c2c24a1c5bc" - integrity sha512-9Xz1uz61fCMtQ0MTTnTIDqUpWWi+livCUsYcIOpUrlJXDHJ7QgDLF3wVKcmLIX727FFyGzesXkVCg8cQuRy8OQ== +react-hook-form@^6.8.3: + version "6.15.1" + resolved "https://registry.npmjs.org/react-hook-form/-/react-hook-form-6.15.1.tgz#57d887ec4dac5a6c0099902171aa39cc893f7bca" + integrity sha512-bL0LQuQ3OlM3JYfbacKtBPLOHhmgYz8Lj6ivMrvu2M6e1wnt4sbGRtPEPYCc/8z3WDbjrMwfAfLX92OsB65pFA== react-hotkeys@2.0.0: version "2.0.0" @@ -19476,11 +19476,6 @@ typescript@^3.8.3: resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.8.3.tgz#409eb8544ea0335711205869ec458ab109ee1061" integrity sha512-MYlEfn5VrLNsgudQTVJeNaQFUAI7DkhnOjdpAp4T+ku1TfQClewlbSuTVHiA+8skNBgaf02TL/kLOvig4y3G8w== -typescript@^4.1.3: - version "4.1.3" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.1.3.tgz#519d582bd94cba0cf8934c7d8e8467e473f53bb7" - integrity sha512-B3ZIOf1IKeH2ixgHhj6la6xdwR9QrLC5d1VKeCSY4tvkqhF2eqd9O7txNlS0PO3GrBAFIdr3L1ndNwteUbZLYg== - ua-parser-js@^0.7.18: version "0.7.21" resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.21.tgz#853cf9ce93f642f67174273cc34565ae6f308777"