From 6250ccdaef45f6de3e2453174dc1de9c303d443b Mon Sep 17 00:00:00 2001 From: Stan Silvert Date: Fri, 10 Dec 2021 05:53:21 -0500 Subject: [PATCH] Complete partial import functionality. (#1644) Co-authored-by: Jon Koops --- .../partial-import-test-data/multi-realm.json | 4 +- .../integration/partial_import_test.spec.ts | 41 +- .../realm_settings/PartialImportModal.ts | 36 +- package-lock.json | 14 +- package.json | 2 +- src/common-messages.ts | 1 + src/realm-settings/PartialImport.tsx | 574 +++++++++++------- src/realm-settings/messages.ts | 14 + 8 files changed, 423 insertions(+), 263 deletions(-) diff --git a/cypress/fixtures/partial-import-test-data/multi-realm.json b/cypress/fixtures/partial-import-test-data/multi-realm.json index ec1bf85525..3f91089168 100644 --- a/cypress/fixtures/partial-import-test-data/multi-realm.json +++ b/cypress/fixtures/partial-import-test-data/multi-realm.json @@ -74,8 +74,6 @@ } ], "client": { - "database-service": [], - "admin-client": [], "realm-management": [ { "id": "3b939f75-d013-4096-8462-48aa39261293", @@ -101,7 +99,7 @@ "secret": "password" }, { - "clientId": "customer-portal", + "clientId": "customer-portal2", "enabled": true, "adminUrl": "/customer-portal", "baseUrl": "/customer-portal", diff --git a/cypress/integration/partial_import_test.spec.ts b/cypress/integration/partial_import_test.spec.ts index dda84f202e..088e016733 100644 --- a/cypress/integration/partial_import_test.spec.ts +++ b/cypress/integration/partial_import_test.spec.ts @@ -59,12 +59,12 @@ describe("Partial import test", () => { modal.importButton().should("be.disabled"); // verify resource counts - modal.userCount().contains("1 users"); - modal.groupCount().contains("1 groups"); - modal.clientCount().contains("1 clients"); - modal.idpCount().contains("1 identity providers"); - modal.realmRolesCount().contains("2 realm roles"); - modal.clientRolesCount().contains("1 client roles"); + modal.userCount().contains("1 Users"); + modal.groupCount().contains("1 Groups"); + modal.clientCount().contains("1 Clients"); + modal.idpCount().contains("1 Identity providers"); + modal.realmRolesCount().contains("2 Realm roles"); + modal.clientRolesCount().contains("1 Client roles"); // import button should disable when switching realms modal.usersCheckbox().click(); @@ -72,23 +72,36 @@ describe("Partial import test", () => { modal.selectRealm("realm2"); modal.importButton().should("be.disabled"); - modal.clientCount().contains("2 clients"); + modal.clientCount().contains("2 Clients"); + + modal.clientsCheckbox().click(); + modal.importButton().click(); + + cy.contains("2 records added"); + cy.contains("customer-portal"); + cy.contains("customer-portal2"); }); - it("Displays user options after realmless import", () => { + it("Displays user options after realmless import and does the import", () => { modal.open(); modal.typeResourceFile("client-only.json"); modal.realmSelector().should("not.exist"); - modal.clientCount().contains("1 clients"); + modal.clientCount().contains("1 Clients"); - modal.userCount().should("not.exist"); - modal.groupCount().should("not.exist"); - modal.idpCount().should("not.exist"); - modal.realmRolesCount().should("not.exist"); - modal.clientRolesCount().should("not.exist"); + modal.usersCheckbox().should("not.exist"); + modal.groupsCheckbox().should("not.exist"); + modal.idpCheckbox().should("not.exist"); + modal.realmRolesCheckbox().should("not.exist"); + modal.clientRolesCheckbox().should("not.exist"); + + modal.clientsCheckbox().click(); + modal.importButton().click(); + + cy.contains("One record added"); + cy.contains("customer-portal"); }); // Unfortunately, the PatternFly FileUpload component does not create an id for the clear button. So we can't easily test that function right now. diff --git a/cypress/support/pages/admin_console/configure/realm_settings/PartialImportModal.ts b/cypress/support/pages/admin_console/configure/realm_settings/PartialImportModal.ts index b63a3fc5f7..0057d56673 100644 --- a/cypress/support/pages/admin_console/configure/realm_settings/PartialImportModal.ts +++ b/cypress/support/pages/admin_console/configure/realm_settings/PartialImportModal.ts @@ -30,36 +30,52 @@ export default class GroupModal { return cy.findByTestId("cancel-button"); } - groupsCheckbox() { - return cy.findByTestId("groups-checkbox"); - } - usersCheckbox() { return cy.findByTestId("users-checkbox"); } + clientsCheckbox() { + return cy.findByTestId("clients-checkbox"); + } + + groupsCheckbox() { + return cy.findByTestId("groups-checkbox"); + } + + idpCheckbox() { + return cy.findByTestId("identityProviders-checkbox"); + } + + realmRolesCheckbox() { + return cy.findByTestId("realmRoles-checkbox"); + } + + clientRolesCheckbox() { + return cy.findByTestId("clientRoles-checkbox"); + } + userCount() { - return cy.findByTestId("users-count"); + return this.usersCheckbox().get("label"); } clientCount() { - return cy.findByTestId("clients-count"); + return this.clientsCheckbox().get("label"); } groupCount() { - return cy.findByTestId("groups-count"); + return this.groupsCheckbox().get("label"); } idpCount() { - return cy.findByTestId("identityProviders-count"); + return this.idpCheckbox().get("label"); } realmRolesCount() { - return cy.findByTestId("realmRoles-count"); + return this.realmRolesCheckbox().get("label"); } clientRolesCount() { - return cy.findByTestId("clientRoles-count"); + return this.clientRolesCheckbox().get("label"); } realmSelector() { diff --git a/package-lock.json b/package-lock.json index 259937f8f9..bd45b17ee5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,7 +7,7 @@ "name": "keycloak-admin-ui", "license": "Apache", "dependencies": { - "@keycloak/keycloak-admin-client": "^16.0.0-dev.59", + "@keycloak/keycloak-admin-client": "^16.0.0-dev.61", "@patternfly/patternfly": "^4.159.1", "@patternfly/react-code-editor": "^4.16.4", "@patternfly/react-core": "^4.175.4", @@ -3407,9 +3407,9 @@ } }, "node_modules/@keycloak/keycloak-admin-client": { - "version": "16.0.0-dev.59", - "resolved": "https://registry.npmjs.org/@keycloak/keycloak-admin-client/-/keycloak-admin-client-16.0.0-dev.59.tgz", - "integrity": "sha512-ygDXfVh7MRGbWNA/8zloWh5ULqhukZ+dhptGKuLmN1kxirzsc0P9//96/EYI3FX9rf+xiuF575dkOsR6sQx5Eg==", + "version": "16.0.0-dev.61", + "resolved": "https://registry.npmjs.org/@keycloak/keycloak-admin-client/-/keycloak-admin-client-16.0.0-dev.61.tgz", + "integrity": "sha512-xeJTOQevOeHe8bQfu3/6Y9Lsort3Ep/VgzieUKmBHdR25kkMyQEUtZGX/7nQKYofjONG2m/KWivNwf0OZp+rhg==", "dependencies": { "axios": "^0.24.0", "camelize-ts": "^1.0.8", @@ -24119,9 +24119,9 @@ } }, "@keycloak/keycloak-admin-client": { - "version": "16.0.0-dev.59", - "resolved": "https://registry.npmjs.org/@keycloak/keycloak-admin-client/-/keycloak-admin-client-16.0.0-dev.59.tgz", - "integrity": "sha512-ygDXfVh7MRGbWNA/8zloWh5ULqhukZ+dhptGKuLmN1kxirzsc0P9//96/EYI3FX9rf+xiuF575dkOsR6sQx5Eg==", + "version": "16.0.0-dev.61", + "resolved": "https://registry.npmjs.org/@keycloak/keycloak-admin-client/-/keycloak-admin-client-16.0.0-dev.61.tgz", + "integrity": "sha512-xeJTOQevOeHe8bQfu3/6Y9Lsort3Ep/VgzieUKmBHdR25kkMyQEUtZGX/7nQKYofjONG2m/KWivNwf0OZp+rhg==", "requires": { "axios": "^0.24.0", "camelize-ts": "^1.0.8", diff --git a/package.json b/package.json index 0f7c657dee..4d8571cc72 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "prepare": "husky install" }, "dependencies": { - "@keycloak/keycloak-admin-client": "^16.0.0-dev.59", + "@keycloak/keycloak-admin-client": "^16.0.0-dev.61", "@patternfly/patternfly": "^4.159.1", "@patternfly/react-code-editor": "^4.16.4", "@patternfly/react-core": "^4.175.4", diff --git a/src/common-messages.ts b/src/common-messages.ts index 9b89b1b19a..2c66630f50 100644 --- a/src/common-messages.ts +++ b/src/common-messages.ts @@ -81,6 +81,7 @@ export default { clients: "Clients", clientScopes: "Client scopes", realmRoles: "Realm roles", + clientRoles: "Client roles", users: "Users", groups: "Groups", sessions: "Sessions", diff --git a/src/realm-settings/PartialImport.tsx b/src/realm-settings/PartialImport.tsx index 12aa6b5996..44f3322247 100644 --- a/src/realm-settings/PartialImport.tsx +++ b/src/realm-settings/PartialImport.tsx @@ -1,16 +1,17 @@ import React, { useState, useEffect } from "react"; import { useTranslation } from "react-i18next"; -import _ from "lodash"; import { + Alert, Button, ButtonVariant, + Checkbox, DataList, DataListCell, DataListItem, DataListItemCells, DataListItemRow, - DataListCheck, Divider, + Label, Modal, ModalVariant, Select, @@ -22,7 +23,20 @@ import { TextContent, } from "@patternfly/react-core"; +import { useAlerts } from "../components/alert/Alerts"; import { JsonFileUpload } from "../components/json-file-upload/JsonFileUpload"; +import { KeycloakDataTable } from "../components/table-toolbar/KeycloakDataTable"; + +import { useAdminClient } from "../context/auth/AdminClient"; +import { useRealm } from "../context/realm-context/RealmContext"; + +import type RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation"; +import type { + PartialImportRealmRepresentation, + PartialImportResponse, + PartialImportResult, +} from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation"; +import type RoleRepresentation from "@keycloak/keycloak-admin-client/lib/defs/roleRepresentation"; export type PartialImportProps = { open: boolean; @@ -31,22 +45,7 @@ export type PartialImportProps = { // An imported JSON file can either be an array of realm objects // or a single realm object. -type ImportedMultiRealm = [ImportedRealm?] | ImportedRealm; - -// Realms in imported json can have a lot more properties, -// but these are the ones we care about. -type ImportedRealm = { - id?: string; - realm?: string; - users?: []; - clients?: []; - groups?: []; - identityProviders?: []; - roles?: { - realm?: []; - client?: { [index: string]: [] }; - }; -}; +type ImportedMultiRealm = RealmRepresentation | RealmRepresentation[]; type NonRoleResource = "users" | "clients" | "groups" | "identityProviders"; type RoleResource = "realmRoles" | "clientRoles"; @@ -56,42 +55,43 @@ type CollisionOption = "FAIL" | "SKIP" | "OVERWRITE"; type ResourceChecked = { [k in Resource]: boolean }; +const INITIAL_RESOURCES: Readonly = { + users: false, + clients: false, + groups: false, + identityProviders: false, + realmRoles: false, + clientRoles: false, +}; + export const PartialImportDialog = (props: PartialImportProps) => { const tRealm = useTranslation("realm-settings").t; const { t } = useTranslation("partial-import"); + const adminClient = useAdminClient(); + const { realm } = useRealm(); - const [isFileSelected, setIsFileSelected] = useState(false); - const [isMultiRealm, setIsMultiRealm] = useState(false); - const [importedFile, setImportedFile] = useState([]); + const [importedFile, setImportedFile] = useState(); + const isFileSelected = !!importedFile; const [isRealmSelectOpen, setIsRealmSelectOpen] = useState(false); const [isCollisionSelectOpen, setIsCollisionSelectOpen] = useState(false); + const [importInProgress, setImportInProgress] = useState(false); const [collisionOption, setCollisionOption] = useState("FAIL"); - const [targetRealm, setTargetRealm] = useState({}); + const [targetRealm, setTargetRealm] = useState({}); + const [importResponse, setImportResponse] = useState(); + const { addError } = useAlerts(); - const allResourcesUnChecked: Readonly = { - users: false, - clients: false, - groups: false, - identityProviders: false, - realmRoles: false, - clientRoles: false, - }; - - const [resourcesToImport, setResourcesToImport] = useState( - _.cloneDeep(allResourcesUnChecked) + const [resourcesToImport, setResourcesToImport] = useState(INITIAL_RESOURCES); + const isAnyResourceChecked = Object.values(resourcesToImport).some( + (checked) => checked ); - const [isAnyResourceChecked, setIsAnyResourceChecked] = useState(false); - const resetResourcesToImport = () => { - setResourcesToImport(_.cloneDeep(allResourcesUnChecked)); - setIsAnyResourceChecked(false); + setResourcesToImport(INITIAL_RESOURCES); }; const resetInputState = () => { - setIsMultiRealm(false); - setImportedFile([]); + setImportedFile(undefined); setTargetRealm({}); setCollisionOption("FAIL"); resetResourcesToImport(); @@ -99,30 +99,24 @@ export const PartialImportDialog = (props: PartialImportProps) => { // when dialog opens or closes, clear state useEffect(() => { - setIsFileSelected(false); + setImportInProgress(false); + setImportResponse(undefined); resetInputState(); }, [props.open]); - const handleFileChange = (value: object) => { - setIsFileSelected(!!value); + const handleFileChange = (value: ImportedMultiRealm) => { resetInputState(); - setImportedFile(value); - if (value instanceof Array && value.length > 0) { - setIsMultiRealm(value.length > 1); - setTargetRealm(value[0] || {}); - } else { - setIsMultiRealm(false); - setTargetRealm((value as ImportedRealm) || {}); + if (!Array.isArray(value)) { + setTargetRealm(value); + } else if (value.length > 0) { + setTargetRealm(value[0]); } }; - const handleRealmSelect = ( - event: React.ChangeEvent | React.MouseEvent, - realm: string | SelectOptionObject - ) => { - setTargetRealm(realm as ImportedRealm); + const handleRealmSelect = (realm: string | SelectOptionObject) => { + setTargetRealm(realm as RealmRepresentation); setIsRealmSelectOpen(false); resetResourcesToImport(); }; @@ -131,30 +125,24 @@ export const PartialImportDialog = (props: PartialImportProps) => { checked: boolean, event: React.FormEvent ) => { - const resource: Resource = event.currentTarget.name as Resource; - const copyOfResourcesToImport = _.cloneDeep(resourcesToImport); - copyOfResourcesToImport[resource] = checked; - setResourcesToImport(copyOfResourcesToImport); - setIsAnyResourceChecked(resourcesChecked(copyOfResourcesToImport)); + const resource = event.currentTarget.name as Resource; + + setResourcesToImport({ + ...resourcesToImport, + [resource]: checked, + }); }; - const realmSelectOptions = () => { - if (!isMultiRealm) return []; - - const mapper = (realm: ImportedRealm) => { - return ( - - {realm.realm || realm.id} - - ); - }; - - return (importedFile as [ImportedRealm]).map(mapper); - }; + const realmSelectOptions = (realms: RealmRepresentation[]) => + realms.map((realm) => ( + + {realm.realm || realm.id} + + )); const handleCollisionSelect = ( event: React.ChangeEvent | React.MouseEvent, @@ -190,66 +178,41 @@ export const PartialImportDialog = (props: PartialImportProps) => { }; const targetHasResource = (resource: NonRoleResource) => { - return ( - targetRealm && - targetRealm[resource] instanceof Array && - targetRealm[resource]!.length > 0 - ); - }; - - const targetHasRoles = () => { - return ( - targetRealm && Object.prototype.hasOwnProperty.call(targetRealm, "roles") - ); + const value = targetRealm[resource]; + return value !== undefined && value.length > 0; }; const targetHasRealmRoles = () => { - return ( - targetHasRoles() && - targetRealm.roles!.realm instanceof Array && - targetRealm.roles!.realm.length > 0 - ); + const value = targetRealm.roles?.realm; + return value !== undefined && value.length > 0; }; const targetHasClientRoles = () => { - return ( - targetHasRoles() && - Object.prototype.hasOwnProperty.call(targetRealm.roles, "client") && - Object.keys(targetRealm.roles!.client!).length > 0 - ); + const value = targetRealm.roles?.client; + return value !== undefined && Object.keys(value).length > 0; }; const itemCount = (resource: Resource) => { if (!isFileSelected) return 0; - if (targetHasRealmRoles() && resource === "realmRoles") - return targetRealm.roles!.realm!.length; - - if (targetHasClientRoles() && resource == "clientRoles") - return clientRolesCount(targetRealm.roles!.client!); - - if (!targetRealm[resource as NonRoleResource]) return 0; - - return targetRealm[resource as NonRoleResource]!.length; - }; - - const clientRolesCount = (clientRoles: { [index: string]: [] }) => { - let total = 0; - for (const clientName in clientRoles) { - total += clientRoles[clientName].length; - } - return total; - }; - - const resourcesChecked = (resources: ResourceChecked) => { - let resource: Resource; - for (resource in resources) { - if (resources[resource]) return true; + if (resource === "realmRoles") { + return targetRealm.roles?.realm?.length ?? 0; } - return false; + if (resource === "clientRoles") { + return targetHasClientRoles() + ? clientRolesCount(targetRealm.roles!.client!) + : 0; + } + + return targetRealm[resource]?.length ?? 0; }; + const clientRolesCount = ( + clientRoles: Record + ) => + Object.values(clientRoles).reduce((total, role) => total + role.length, 0); + const resourceDataListItem = ( resource: Resource, resourceDisplayName: string @@ -257,19 +220,18 @@ export const PartialImportDialog = (props: PartialImportProps) => { return ( - - - {itemCount(resource)} {resourceDisplayName} - + , ]} /> @@ -278,107 +240,263 @@ export const PartialImportDialog = (props: PartialImportProps) => { ); }; - return ( - { - props.toggleDialog(); - }} - > - {t("import")} - , - , - ]} - > - - - - {t("partialImportHeaderText")} - - - - - + const jsonForImport = () => { + const jsonToImport: PartialImportRealmRepresentation = { + ifResourceExists: collisionOption, + id: targetRealm.id, + realm: targetRealm.realm, + }; - {isFileSelected && targetHasResources() && ( - <> - - - - {isMultiRealm && ( + if (resourcesToImport["users"]) jsonToImport.users = targetRealm.users; + if (resourcesToImport["groups"]) jsonToImport.groups = targetRealm.groups; + if (resourcesToImport["identityProviders"]) + jsonToImport.identityProviders = targetRealm.identityProviders; + if (resourcesToImport["clients"]) + jsonToImport.clients = targetRealm.clients; + if (resourcesToImport["realmRoles"] || resourcesToImport["clientRoles"]) { + jsonToImport.roles = targetRealm.roles; + if (!resourcesToImport["realmRoles"]) delete jsonToImport.roles?.realm; + if (!resourcesToImport["clientRoles"]) delete jsonToImport.roles?.client; + } + return jsonToImport; + }; + + async function doImport() { + if (importInProgress) return; + + setImportInProgress(true); + + try { + const importResults = await adminClient.realms.partialImport({ + realm, + rep: jsonForImport(), + }); + setImportResponse(importResults); + } catch (error) { + addError("partial-import:importFail", error); + } + + setImportInProgress(false); + } + + const importModal = () => { + return ( + { + doImport(); + }} + > + {t("import")} + , + , + ]} + > + + + + {t("partialImportHeaderText")} + + + + + + + {isFileSelected && targetHasResources() && ( + <> - {t("selectRealm")}: + + + {Array.isArray(importedFile) && importedFile.length > 1 && ( + + {t("selectRealm")}: + + + )} + + {t("chooseResources")}: + + {targetHasResource("users") && + resourceDataListItem("users", t("common:users"))} + {targetHasResource("groups") && + resourceDataListItem("groups", t("common:groups"))} + {targetHasResource("clients") && + resourceDataListItem("clients", t("common:clients"))} + {targetHasResource("identityProviders") && + resourceDataListItem( + "identityProviders", + t("common:identityProviders") + )} + {targetHasRealmRoles() && + resourceDataListItem("realmRoles", t("common:realmRoles"))} + {targetHasClientRoles() && + resourceDataListItem( + "clientRoles", + t("common:clientRoles") + )} + + + + {t("selectIfResourceExists")}: - )} - - {t("chooseResources")}: - - {targetHasResource("users") && - resourceDataListItem("users", "users")} - {targetHasResource("groups") && - resourceDataListItem("groups", "groups")} - {targetHasResource("clients") && - resourceDataListItem("clients", "clients")} - {targetHasResource("identityProviders") && - resourceDataListItem( - "identityProviders", - "identity providers" - )} - {targetHasRealmRoles() && - resourceDataListItem("realmRoles", "realm roles")} - {targetHasClientRoles() && - resourceDataListItem("clientRoles", "client roles")} - - - - {t("selectIfResourceExists")}: - - - - )} - - - ); + + )} + + + ); + }; + + const importCompleteMessage = () => { + return `${t("importAdded", { + count: importResponse?.added, + })} ${t("importSkipped", { + count: importResponse?.skipped, + })} ${t("importOverwritten", { + count: importResponse?.overwritten, + })}`; + }; + + const loader = async (first = 0, max = 15) => { + if (!importResponse) { + return []; + } + + const last = Math.min(first + max, importResponse.results.length); + + return importResponse.results.slice(first, last); + }; + + const ActionLabel = (importRecord: PartialImportResult) => { + switch (importRecord.action) { + case "ADDED": + return ( + + ); + case "SKIPPED": + return ( + + ); + case "OVERWRITTEN": + return ( + + ); + } + }; + + const TypeRenderer = (importRecord: PartialImportResult) => { + const typeMap = new Map([ + ["CLIENT", t("common:clients")], + ["REALM_ROLE", t("common:realmRoles")], + ["USER", t("common:users")], + ["CLIENT_ROLE", t("common:clientRoles")], + ["IDP", t("common:identityProviders")], + ]); + + return {typeMap.get(importRecord.resourceType)}; + }; + + const importCompletedModal = () => { + return ( + { + props.toggleDialog(); + }} + > + {t("common:close")} + , + ]} + > + + + + ); + }; + + if (!importResponse) { + return importModal(); + } + + return importCompletedModal(); }; diff --git a/src/realm-settings/messages.ts b/src/realm-settings/messages.ts index f4560ce2d5..fa48421ed7 100644 --- a/src/realm-settings/messages.ts +++ b/src/realm-settings/messages.ts @@ -743,9 +743,23 @@ export default { selectIfResourceExists: "If a resource already exists, specify what should be done", import: "Import", + resourcesToImport: "Resources to import", + importFail: "Import failed: {{error}}", FAIL: "Fail import", SKIP: "Skip", OVERWRITE: "Overwrite", + added: "Added", + skipped: "Skipped", + overwritten: "Overwritten", + importAdded_zero: "No records added.", + importAdded_one: "One record added.", + importAdded_other: "{{count}} records added.", + importOverwritten_zero: "No records overwritten.", + importOverwritten_one: "One record overwritten.", + importOverwritten_other: "{{count}} records overwritten.", + importSkipped_zero: "No records skipped.", + importSkipped_one: "One record skipped.", + importSkipped_other: "{{count}} records skipped.", }, "partial-export": { partialExportHeaderText: