Complete partial import functionality. (#1644)
Co-authored-by: Jon Koops <jonkoops@gmail.com>
This commit is contained in:
parent
242c1d8445
commit
6250ccdaef
8 changed files with 423 additions and 263 deletions
|
@ -74,8 +74,6 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"client": {
|
"client": {
|
||||||
"database-service": [],
|
|
||||||
"admin-client": [],
|
|
||||||
"realm-management": [
|
"realm-management": [
|
||||||
{
|
{
|
||||||
"id": "3b939f75-d013-4096-8462-48aa39261293",
|
"id": "3b939f75-d013-4096-8462-48aa39261293",
|
||||||
|
@ -101,7 +99,7 @@
|
||||||
"secret": "password"
|
"secret": "password"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"clientId": "customer-portal",
|
"clientId": "customer-portal2",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"adminUrl": "/customer-portal",
|
"adminUrl": "/customer-portal",
|
||||||
"baseUrl": "/customer-portal",
|
"baseUrl": "/customer-portal",
|
||||||
|
|
|
@ -59,12 +59,12 @@ describe("Partial import test", () => {
|
||||||
modal.importButton().should("be.disabled");
|
modal.importButton().should("be.disabled");
|
||||||
|
|
||||||
// verify resource counts
|
// verify resource counts
|
||||||
modal.userCount().contains("1 users");
|
modal.userCount().contains("1 Users");
|
||||||
modal.groupCount().contains("1 groups");
|
modal.groupCount().contains("1 Groups");
|
||||||
modal.clientCount().contains("1 clients");
|
modal.clientCount().contains("1 Clients");
|
||||||
modal.idpCount().contains("1 identity providers");
|
modal.idpCount().contains("1 Identity providers");
|
||||||
modal.realmRolesCount().contains("2 realm roles");
|
modal.realmRolesCount().contains("2 Realm roles");
|
||||||
modal.clientRolesCount().contains("1 client roles");
|
modal.clientRolesCount().contains("1 Client roles");
|
||||||
|
|
||||||
// import button should disable when switching realms
|
// import button should disable when switching realms
|
||||||
modal.usersCheckbox().click();
|
modal.usersCheckbox().click();
|
||||||
|
@ -72,23 +72,36 @@ describe("Partial import test", () => {
|
||||||
modal.selectRealm("realm2");
|
modal.selectRealm("realm2");
|
||||||
modal.importButton().should("be.disabled");
|
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.open();
|
||||||
|
|
||||||
modal.typeResourceFile("client-only.json");
|
modal.typeResourceFile("client-only.json");
|
||||||
|
|
||||||
modal.realmSelector().should("not.exist");
|
modal.realmSelector().should("not.exist");
|
||||||
|
|
||||||
modal.clientCount().contains("1 clients");
|
modal.clientCount().contains("1 Clients");
|
||||||
|
|
||||||
modal.userCount().should("not.exist");
|
modal.usersCheckbox().should("not.exist");
|
||||||
modal.groupCount().should("not.exist");
|
modal.groupsCheckbox().should("not.exist");
|
||||||
modal.idpCount().should("not.exist");
|
modal.idpCheckbox().should("not.exist");
|
||||||
modal.realmRolesCount().should("not.exist");
|
modal.realmRolesCheckbox().should("not.exist");
|
||||||
modal.clientRolesCount().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.
|
// Unfortunately, the PatternFly FileUpload component does not create an id for the clear button. So we can't easily test that function right now.
|
||||||
|
|
|
@ -30,36 +30,52 @@ export default class GroupModal {
|
||||||
return cy.findByTestId("cancel-button");
|
return cy.findByTestId("cancel-button");
|
||||||
}
|
}
|
||||||
|
|
||||||
groupsCheckbox() {
|
|
||||||
return cy.findByTestId("groups-checkbox");
|
|
||||||
}
|
|
||||||
|
|
||||||
usersCheckbox() {
|
usersCheckbox() {
|
||||||
return cy.findByTestId("users-checkbox");
|
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() {
|
userCount() {
|
||||||
return cy.findByTestId("users-count");
|
return this.usersCheckbox().get("label");
|
||||||
}
|
}
|
||||||
|
|
||||||
clientCount() {
|
clientCount() {
|
||||||
return cy.findByTestId("clients-count");
|
return this.clientsCheckbox().get("label");
|
||||||
}
|
}
|
||||||
|
|
||||||
groupCount() {
|
groupCount() {
|
||||||
return cy.findByTestId("groups-count");
|
return this.groupsCheckbox().get("label");
|
||||||
}
|
}
|
||||||
|
|
||||||
idpCount() {
|
idpCount() {
|
||||||
return cy.findByTestId("identityProviders-count");
|
return this.idpCheckbox().get("label");
|
||||||
}
|
}
|
||||||
|
|
||||||
realmRolesCount() {
|
realmRolesCount() {
|
||||||
return cy.findByTestId("realmRoles-count");
|
return this.realmRolesCheckbox().get("label");
|
||||||
}
|
}
|
||||||
|
|
||||||
clientRolesCount() {
|
clientRolesCount() {
|
||||||
return cy.findByTestId("clientRoles-count");
|
return this.clientRolesCheckbox().get("label");
|
||||||
}
|
}
|
||||||
|
|
||||||
realmSelector() {
|
realmSelector() {
|
||||||
|
|
14
package-lock.json
generated
14
package-lock.json
generated
|
@ -7,7 +7,7 @@
|
||||||
"name": "keycloak-admin-ui",
|
"name": "keycloak-admin-ui",
|
||||||
"license": "Apache",
|
"license": "Apache",
|
||||||
"dependencies": {
|
"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/patternfly": "^4.159.1",
|
||||||
"@patternfly/react-code-editor": "^4.16.4",
|
"@patternfly/react-code-editor": "^4.16.4",
|
||||||
"@patternfly/react-core": "^4.175.4",
|
"@patternfly/react-core": "^4.175.4",
|
||||||
|
@ -3407,9 +3407,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@keycloak/keycloak-admin-client": {
|
"node_modules/@keycloak/keycloak-admin-client": {
|
||||||
"version": "16.0.0-dev.59",
|
"version": "16.0.0-dev.61",
|
||||||
"resolved": "https://registry.npmjs.org/@keycloak/keycloak-admin-client/-/keycloak-admin-client-16.0.0-dev.59.tgz",
|
"resolved": "https://registry.npmjs.org/@keycloak/keycloak-admin-client/-/keycloak-admin-client-16.0.0-dev.61.tgz",
|
||||||
"integrity": "sha512-ygDXfVh7MRGbWNA/8zloWh5ULqhukZ+dhptGKuLmN1kxirzsc0P9//96/EYI3FX9rf+xiuF575dkOsR6sQx5Eg==",
|
"integrity": "sha512-xeJTOQevOeHe8bQfu3/6Y9Lsort3Ep/VgzieUKmBHdR25kkMyQEUtZGX/7nQKYofjONG2m/KWivNwf0OZp+rhg==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^0.24.0",
|
"axios": "^0.24.0",
|
||||||
"camelize-ts": "^1.0.8",
|
"camelize-ts": "^1.0.8",
|
||||||
|
@ -24119,9 +24119,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"@keycloak/keycloak-admin-client": {
|
"@keycloak/keycloak-admin-client": {
|
||||||
"version": "16.0.0-dev.59",
|
"version": "16.0.0-dev.61",
|
||||||
"resolved": "https://registry.npmjs.org/@keycloak/keycloak-admin-client/-/keycloak-admin-client-16.0.0-dev.59.tgz",
|
"resolved": "https://registry.npmjs.org/@keycloak/keycloak-admin-client/-/keycloak-admin-client-16.0.0-dev.61.tgz",
|
||||||
"integrity": "sha512-ygDXfVh7MRGbWNA/8zloWh5ULqhukZ+dhptGKuLmN1kxirzsc0P9//96/EYI3FX9rf+xiuF575dkOsR6sQx5Eg==",
|
"integrity": "sha512-xeJTOQevOeHe8bQfu3/6Y9Lsort3Ep/VgzieUKmBHdR25kkMyQEUtZGX/7nQKYofjONG2m/KWivNwf0OZp+rhg==",
|
||||||
"requires": {
|
"requires": {
|
||||||
"axios": "^0.24.0",
|
"axios": "^0.24.0",
|
||||||
"camelize-ts": "^1.0.8",
|
"camelize-ts": "^1.0.8",
|
||||||
|
|
|
@ -23,7 +23,7 @@
|
||||||
"prepare": "husky install"
|
"prepare": "husky install"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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/patternfly": "^4.159.1",
|
||||||
"@patternfly/react-code-editor": "^4.16.4",
|
"@patternfly/react-code-editor": "^4.16.4",
|
||||||
"@patternfly/react-core": "^4.175.4",
|
"@patternfly/react-core": "^4.175.4",
|
||||||
|
|
|
@ -81,6 +81,7 @@ export default {
|
||||||
clients: "Clients",
|
clients: "Clients",
|
||||||
clientScopes: "Client scopes",
|
clientScopes: "Client scopes",
|
||||||
realmRoles: "Realm roles",
|
realmRoles: "Realm roles",
|
||||||
|
clientRoles: "Client roles",
|
||||||
users: "Users",
|
users: "Users",
|
||||||
groups: "Groups",
|
groups: "Groups",
|
||||||
sessions: "Sessions",
|
sessions: "Sessions",
|
||||||
|
|
|
@ -1,16 +1,17 @@
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import _ from "lodash";
|
|
||||||
import {
|
import {
|
||||||
|
Alert,
|
||||||
Button,
|
Button,
|
||||||
ButtonVariant,
|
ButtonVariant,
|
||||||
|
Checkbox,
|
||||||
DataList,
|
DataList,
|
||||||
DataListCell,
|
DataListCell,
|
||||||
DataListItem,
|
DataListItem,
|
||||||
DataListItemCells,
|
DataListItemCells,
|
||||||
DataListItemRow,
|
DataListItemRow,
|
||||||
DataListCheck,
|
|
||||||
Divider,
|
Divider,
|
||||||
|
Label,
|
||||||
Modal,
|
Modal,
|
||||||
ModalVariant,
|
ModalVariant,
|
||||||
Select,
|
Select,
|
||||||
|
@ -22,7 +23,20 @@ import {
|
||||||
TextContent,
|
TextContent,
|
||||||
} from "@patternfly/react-core";
|
} from "@patternfly/react-core";
|
||||||
|
|
||||||
|
import { useAlerts } from "../components/alert/Alerts";
|
||||||
import { JsonFileUpload } from "../components/json-file-upload/JsonFileUpload";
|
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 = {
|
export type PartialImportProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
@ -31,22 +45,7 @@ export type PartialImportProps = {
|
||||||
|
|
||||||
// An imported JSON file can either be an array of realm objects
|
// An imported JSON file can either be an array of realm objects
|
||||||
// or a single realm object.
|
// or a single realm object.
|
||||||
type ImportedMultiRealm = [ImportedRealm?] | ImportedRealm;
|
type ImportedMultiRealm = RealmRepresentation | RealmRepresentation[];
|
||||||
|
|
||||||
// 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 NonRoleResource = "users" | "clients" | "groups" | "identityProviders";
|
type NonRoleResource = "users" | "clients" | "groups" | "identityProviders";
|
||||||
type RoleResource = "realmRoles" | "clientRoles";
|
type RoleResource = "realmRoles" | "clientRoles";
|
||||||
|
@ -56,42 +55,43 @@ type CollisionOption = "FAIL" | "SKIP" | "OVERWRITE";
|
||||||
|
|
||||||
type ResourceChecked = { [k in Resource]: boolean };
|
type ResourceChecked = { [k in Resource]: boolean };
|
||||||
|
|
||||||
|
const INITIAL_RESOURCES: Readonly<ResourceChecked> = {
|
||||||
|
users: false,
|
||||||
|
clients: false,
|
||||||
|
groups: false,
|
||||||
|
identityProviders: false,
|
||||||
|
realmRoles: false,
|
||||||
|
clientRoles: false,
|
||||||
|
};
|
||||||
|
|
||||||
export const PartialImportDialog = (props: PartialImportProps) => {
|
export const PartialImportDialog = (props: PartialImportProps) => {
|
||||||
const tRealm = useTranslation("realm-settings").t;
|
const tRealm = useTranslation("realm-settings").t;
|
||||||
const { t } = useTranslation("partial-import");
|
const { t } = useTranslation("partial-import");
|
||||||
|
const adminClient = useAdminClient();
|
||||||
|
const { realm } = useRealm();
|
||||||
|
|
||||||
const [isFileSelected, setIsFileSelected] = useState(false);
|
const [importedFile, setImportedFile] = useState<ImportedMultiRealm>();
|
||||||
const [isMultiRealm, setIsMultiRealm] = useState(false);
|
const isFileSelected = !!importedFile;
|
||||||
const [importedFile, setImportedFile] = useState<ImportedMultiRealm>([]);
|
|
||||||
const [isRealmSelectOpen, setIsRealmSelectOpen] = useState(false);
|
const [isRealmSelectOpen, setIsRealmSelectOpen] = useState(false);
|
||||||
const [isCollisionSelectOpen, setIsCollisionSelectOpen] = useState(false);
|
const [isCollisionSelectOpen, setIsCollisionSelectOpen] = useState(false);
|
||||||
|
const [importInProgress, setImportInProgress] = useState(false);
|
||||||
const [collisionOption, setCollisionOption] =
|
const [collisionOption, setCollisionOption] =
|
||||||
useState<CollisionOption>("FAIL");
|
useState<CollisionOption>("FAIL");
|
||||||
const [targetRealm, setTargetRealm] = useState<ImportedRealm>({});
|
const [targetRealm, setTargetRealm] = useState<RealmRepresentation>({});
|
||||||
|
const [importResponse, setImportResponse] = useState<PartialImportResponse>();
|
||||||
|
const { addError } = useAlerts();
|
||||||
|
|
||||||
const allResourcesUnChecked: Readonly<ResourceChecked> = {
|
const [resourcesToImport, setResourcesToImport] = useState(INITIAL_RESOURCES);
|
||||||
users: false,
|
const isAnyResourceChecked = Object.values(resourcesToImport).some(
|
||||||
clients: false,
|
(checked) => checked
|
||||||
groups: false,
|
|
||||||
identityProviders: false,
|
|
||||||
realmRoles: false,
|
|
||||||
clientRoles: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
const [resourcesToImport, setResourcesToImport] = useState<ResourceChecked>(
|
|
||||||
_.cloneDeep(allResourcesUnChecked)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const [isAnyResourceChecked, setIsAnyResourceChecked] = useState(false);
|
|
||||||
|
|
||||||
const resetResourcesToImport = () => {
|
const resetResourcesToImport = () => {
|
||||||
setResourcesToImport(_.cloneDeep(allResourcesUnChecked));
|
setResourcesToImport(INITIAL_RESOURCES);
|
||||||
setIsAnyResourceChecked(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const resetInputState = () => {
|
const resetInputState = () => {
|
||||||
setIsMultiRealm(false);
|
setImportedFile(undefined);
|
||||||
setImportedFile([]);
|
|
||||||
setTargetRealm({});
|
setTargetRealm({});
|
||||||
setCollisionOption("FAIL");
|
setCollisionOption("FAIL");
|
||||||
resetResourcesToImport();
|
resetResourcesToImport();
|
||||||
|
@ -99,30 +99,24 @@ export const PartialImportDialog = (props: PartialImportProps) => {
|
||||||
|
|
||||||
// when dialog opens or closes, clear state
|
// when dialog opens or closes, clear state
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsFileSelected(false);
|
setImportInProgress(false);
|
||||||
|
setImportResponse(undefined);
|
||||||
resetInputState();
|
resetInputState();
|
||||||
}, [props.open]);
|
}, [props.open]);
|
||||||
|
|
||||||
const handleFileChange = (value: object) => {
|
const handleFileChange = (value: ImportedMultiRealm) => {
|
||||||
setIsFileSelected(!!value);
|
|
||||||
resetInputState();
|
resetInputState();
|
||||||
|
|
||||||
setImportedFile(value);
|
setImportedFile(value);
|
||||||
|
|
||||||
if (value instanceof Array && value.length > 0) {
|
if (!Array.isArray(value)) {
|
||||||
setIsMultiRealm(value.length > 1);
|
setTargetRealm(value);
|
||||||
setTargetRealm(value[0] || {});
|
} else if (value.length > 0) {
|
||||||
} else {
|
setTargetRealm(value[0]);
|
||||||
setIsMultiRealm(false);
|
|
||||||
setTargetRealm((value as ImportedRealm) || {});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRealmSelect = (
|
const handleRealmSelect = (realm: string | SelectOptionObject) => {
|
||||||
event: React.ChangeEvent<Element> | React.MouseEvent<Element, MouseEvent>,
|
setTargetRealm(realm as RealmRepresentation);
|
||||||
realm: string | SelectOptionObject
|
|
||||||
) => {
|
|
||||||
setTargetRealm(realm as ImportedRealm);
|
|
||||||
setIsRealmSelectOpen(false);
|
setIsRealmSelectOpen(false);
|
||||||
resetResourcesToImport();
|
resetResourcesToImport();
|
||||||
};
|
};
|
||||||
|
@ -131,30 +125,24 @@ export const PartialImportDialog = (props: PartialImportProps) => {
|
||||||
checked: boolean,
|
checked: boolean,
|
||||||
event: React.FormEvent<HTMLInputElement>
|
event: React.FormEvent<HTMLInputElement>
|
||||||
) => {
|
) => {
|
||||||
const resource: Resource = event.currentTarget.name as Resource;
|
const resource = event.currentTarget.name as Resource;
|
||||||
const copyOfResourcesToImport = _.cloneDeep(resourcesToImport);
|
|
||||||
copyOfResourcesToImport[resource] = checked;
|
setResourcesToImport({
|
||||||
setResourcesToImport(copyOfResourcesToImport);
|
...resourcesToImport,
|
||||||
setIsAnyResourceChecked(resourcesChecked(copyOfResourcesToImport));
|
[resource]: checked,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const realmSelectOptions = () => {
|
const realmSelectOptions = (realms: RealmRepresentation[]) =>
|
||||||
if (!isMultiRealm) return [];
|
realms.map((realm) => (
|
||||||
|
<SelectOption
|
||||||
const mapper = (realm: ImportedRealm) => {
|
key={realm.id}
|
||||||
return (
|
value={realm}
|
||||||
<SelectOption
|
data-testid={realm.id + "-select-option"}
|
||||||
key={realm.id}
|
>
|
||||||
value={realm}
|
{realm.realm || realm.id}
|
||||||
data-testid={realm.id + "-select-option"}
|
</SelectOption>
|
||||||
>
|
));
|
||||||
{realm.realm || realm.id}
|
|
||||||
</SelectOption>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (importedFile as [ImportedRealm]).map(mapper);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCollisionSelect = (
|
const handleCollisionSelect = (
|
||||||
event: React.ChangeEvent<Element> | React.MouseEvent<Element, MouseEvent>,
|
event: React.ChangeEvent<Element> | React.MouseEvent<Element, MouseEvent>,
|
||||||
|
@ -190,66 +178,41 @@ export const PartialImportDialog = (props: PartialImportProps) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const targetHasResource = (resource: NonRoleResource) => {
|
const targetHasResource = (resource: NonRoleResource) => {
|
||||||
return (
|
const value = targetRealm[resource];
|
||||||
targetRealm &&
|
return value !== undefined && value.length > 0;
|
||||||
targetRealm[resource] instanceof Array &&
|
|
||||||
targetRealm[resource]!.length > 0
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const targetHasRoles = () => {
|
|
||||||
return (
|
|
||||||
targetRealm && Object.prototype.hasOwnProperty.call(targetRealm, "roles")
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const targetHasRealmRoles = () => {
|
const targetHasRealmRoles = () => {
|
||||||
return (
|
const value = targetRealm.roles?.realm;
|
||||||
targetHasRoles() &&
|
return value !== undefined && value.length > 0;
|
||||||
targetRealm.roles!.realm instanceof Array &&
|
|
||||||
targetRealm.roles!.realm.length > 0
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const targetHasClientRoles = () => {
|
const targetHasClientRoles = () => {
|
||||||
return (
|
const value = targetRealm.roles?.client;
|
||||||
targetHasRoles() &&
|
return value !== undefined && Object.keys(value).length > 0;
|
||||||
Object.prototype.hasOwnProperty.call(targetRealm.roles, "client") &&
|
|
||||||
Object.keys(targetRealm.roles!.client!).length > 0
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const itemCount = (resource: Resource) => {
|
const itemCount = (resource: Resource) => {
|
||||||
if (!isFileSelected) return 0;
|
if (!isFileSelected) return 0;
|
||||||
|
|
||||||
if (targetHasRealmRoles() && resource === "realmRoles")
|
if (resource === "realmRoles") {
|
||||||
return targetRealm.roles!.realm!.length;
|
return targetRealm.roles?.realm?.length ?? 0;
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
if (resource === "clientRoles") {
|
||||||
|
return targetHasClientRoles()
|
||||||
|
? clientRolesCount(targetRealm.roles!.client!)
|
||||||
|
: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return targetRealm[resource]?.length ?? 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const clientRolesCount = (
|
||||||
|
clientRoles: Record<string, RoleRepresentation[]>
|
||||||
|
) =>
|
||||||
|
Object.values(clientRoles).reduce((total, role) => total + role.length, 0);
|
||||||
|
|
||||||
const resourceDataListItem = (
|
const resourceDataListItem = (
|
||||||
resource: Resource,
|
resource: Resource,
|
||||||
resourceDisplayName: string
|
resourceDisplayName: string
|
||||||
|
@ -257,19 +220,18 @@ export const PartialImportDialog = (props: PartialImportProps) => {
|
||||||
return (
|
return (
|
||||||
<DataListItem aria-labelledby={`${resource}-list-item`}>
|
<DataListItem aria-labelledby={`${resource}-list-item`}>
|
||||||
<DataListItemRow>
|
<DataListItemRow>
|
||||||
<DataListCheck
|
|
||||||
aria-labelledby={`${resource}-checkbox`}
|
|
||||||
name={resource}
|
|
||||||
isChecked={resourcesToImport[resource]}
|
|
||||||
onChange={handleResourceCheckBox}
|
|
||||||
data-testid={resource + "-checkbox"}
|
|
||||||
/>
|
|
||||||
<DataListItemCells
|
<DataListItemCells
|
||||||
dataListCells={[
|
dataListCells={[
|
||||||
<DataListCell key={resource}>
|
<DataListCell key={resource}>
|
||||||
<span data-testid={resource + "-count"}>
|
<Checkbox
|
||||||
{itemCount(resource)} {resourceDisplayName}
|
id={`${resource}-checkbox`}
|
||||||
</span>
|
label={`${itemCount(resource)} ${resourceDisplayName}`}
|
||||||
|
aria-labelledby={`${resource}-checkbox`}
|
||||||
|
name={resource}
|
||||||
|
isChecked={resourcesToImport[resource]}
|
||||||
|
onChange={handleResourceCheckBox}
|
||||||
|
data-testid={resource + "-checkbox"}
|
||||||
|
/>
|
||||||
</DataListCell>,
|
</DataListCell>,
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
@ -278,107 +240,263 @@ export const PartialImportDialog = (props: PartialImportProps) => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const jsonForImport = () => {
|
||||||
<Modal
|
const jsonToImport: PartialImportRealmRepresentation = {
|
||||||
variant={ModalVariant.medium}
|
ifResourceExists: collisionOption,
|
||||||
title={tRealm("partialImport")}
|
id: targetRealm.id,
|
||||||
isOpen={props.open}
|
realm: targetRealm.realm,
|
||||||
onClose={props.toggleDialog}
|
};
|
||||||
actions={[
|
|
||||||
<Button
|
|
||||||
id="modal-import"
|
|
||||||
data-testid="import-button"
|
|
||||||
key="import"
|
|
||||||
isDisabled={!isAnyResourceChecked}
|
|
||||||
onClick={() => {
|
|
||||||
props.toggleDialog();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("import")}
|
|
||||||
</Button>,
|
|
||||||
<Button
|
|
||||||
id="modal-cancel"
|
|
||||||
data-testid="cancel-button"
|
|
||||||
key="cancel"
|
|
||||||
variant={ButtonVariant.link}
|
|
||||||
onClick={() => {
|
|
||||||
props.toggleDialog();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("common:cancel")}
|
|
||||||
</Button>,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Stack hasGutter>
|
|
||||||
<StackItem>
|
|
||||||
<TextContent>
|
|
||||||
<Text>{t("partialImportHeaderText")}</Text>
|
|
||||||
</TextContent>
|
|
||||||
</StackItem>
|
|
||||||
<StackItem>
|
|
||||||
<JsonFileUpload
|
|
||||||
id="partial-import-file"
|
|
||||||
allowEditingUploadedText
|
|
||||||
onChange={handleFileChange}
|
|
||||||
/>
|
|
||||||
</StackItem>
|
|
||||||
|
|
||||||
{isFileSelected && targetHasResources() && (
|
if (resourcesToImport["users"]) jsonToImport.users = targetRealm.users;
|
||||||
<>
|
if (resourcesToImport["groups"]) jsonToImport.groups = targetRealm.groups;
|
||||||
<StackItem>
|
if (resourcesToImport["identityProviders"])
|
||||||
<Divider />
|
jsonToImport.identityProviders = targetRealm.identityProviders;
|
||||||
</StackItem>
|
if (resourcesToImport["clients"])
|
||||||
{isMultiRealm && (
|
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 (
|
||||||
|
<Modal
|
||||||
|
variant={ModalVariant.medium}
|
||||||
|
title={tRealm("partialImport")}
|
||||||
|
isOpen={props.open}
|
||||||
|
onClose={props.toggleDialog}
|
||||||
|
actions={[
|
||||||
|
<Button
|
||||||
|
id="modal-import"
|
||||||
|
data-testid="import-button"
|
||||||
|
key="import"
|
||||||
|
isDisabled={!isAnyResourceChecked}
|
||||||
|
onClick={() => {
|
||||||
|
doImport();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("import")}
|
||||||
|
</Button>,
|
||||||
|
<Button
|
||||||
|
id="modal-cancel"
|
||||||
|
data-testid="cancel-button"
|
||||||
|
key="cancel"
|
||||||
|
variant={ButtonVariant.link}
|
||||||
|
onClick={() => {
|
||||||
|
props.toggleDialog();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("common:cancel")}
|
||||||
|
</Button>,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Stack hasGutter>
|
||||||
|
<StackItem>
|
||||||
|
<TextContent>
|
||||||
|
<Text>{t("partialImportHeaderText")}</Text>
|
||||||
|
</TextContent>
|
||||||
|
</StackItem>
|
||||||
|
<StackItem>
|
||||||
|
<JsonFileUpload
|
||||||
|
id="partial-import-file"
|
||||||
|
allowEditingUploadedText
|
||||||
|
onChange={handleFileChange}
|
||||||
|
/>
|
||||||
|
</StackItem>
|
||||||
|
|
||||||
|
{isFileSelected && targetHasResources() && (
|
||||||
|
<>
|
||||||
<StackItem>
|
<StackItem>
|
||||||
<Text>{t("selectRealm")}:</Text>
|
<Divider />
|
||||||
|
</StackItem>
|
||||||
|
{Array.isArray(importedFile) && importedFile.length > 1 && (
|
||||||
|
<StackItem>
|
||||||
|
<Text>{t("selectRealm")}:</Text>
|
||||||
|
<Select
|
||||||
|
toggleId="realm-selector"
|
||||||
|
isOpen={isRealmSelectOpen}
|
||||||
|
onToggle={() => setIsRealmSelectOpen(!isRealmSelectOpen)}
|
||||||
|
onSelect={(_, value) => handleRealmSelect(value)}
|
||||||
|
placeholderText={targetRealm.realm || targetRealm.id}
|
||||||
|
>
|
||||||
|
{realmSelectOptions(importedFile)}
|
||||||
|
</Select>
|
||||||
|
</StackItem>
|
||||||
|
)}
|
||||||
|
<StackItem>
|
||||||
|
<Text>{t("chooseResources")}:</Text>
|
||||||
|
<DataList aria-label={t("resourcesToImport")} isCompact>
|
||||||
|
{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")
|
||||||
|
)}
|
||||||
|
</DataList>
|
||||||
|
</StackItem>
|
||||||
|
<StackItem>
|
||||||
|
<Text>{t("selectIfResourceExists")}:</Text>
|
||||||
<Select
|
<Select
|
||||||
toggleId="realm-selector"
|
isOpen={isCollisionSelectOpen}
|
||||||
isOpen={isRealmSelectOpen}
|
direction="up"
|
||||||
onToggle={() => setIsRealmSelectOpen(!isRealmSelectOpen)}
|
onToggle={() => {
|
||||||
onSelect={handleRealmSelect}
|
setIsCollisionSelectOpen(!isCollisionSelectOpen);
|
||||||
placeholderText={targetRealm.realm || targetRealm.id}
|
}}
|
||||||
|
onSelect={handleCollisionSelect}
|
||||||
|
placeholderText={t(collisionOption)}
|
||||||
>
|
>
|
||||||
{realmSelectOptions()}
|
{collisionOptions()}
|
||||||
</Select>
|
</Select>
|
||||||
</StackItem>
|
</StackItem>
|
||||||
)}
|
</>
|
||||||
<StackItem>
|
)}
|
||||||
<Text>{t("chooseResources")}:</Text>
|
</Stack>
|
||||||
<DataList aria-label="Resources to import" isCompact>
|
</Modal>
|
||||||
{targetHasResource("users") &&
|
);
|
||||||
resourceDataListItem("users", "users")}
|
};
|
||||||
{targetHasResource("groups") &&
|
|
||||||
resourceDataListItem("groups", "groups")}
|
const importCompleteMessage = () => {
|
||||||
{targetHasResource("clients") &&
|
return `${t("importAdded", {
|
||||||
resourceDataListItem("clients", "clients")}
|
count: importResponse?.added,
|
||||||
{targetHasResource("identityProviders") &&
|
})} ${t("importSkipped", {
|
||||||
resourceDataListItem(
|
count: importResponse?.skipped,
|
||||||
"identityProviders",
|
})} ${t("importOverwritten", {
|
||||||
"identity providers"
|
count: importResponse?.overwritten,
|
||||||
)}
|
})}`;
|
||||||
{targetHasRealmRoles() &&
|
};
|
||||||
resourceDataListItem("realmRoles", "realm roles")}
|
|
||||||
{targetHasClientRoles() &&
|
const loader = async (first = 0, max = 15) => {
|
||||||
resourceDataListItem("clientRoles", "client roles")}
|
if (!importResponse) {
|
||||||
</DataList>
|
return [];
|
||||||
</StackItem>
|
}
|
||||||
<StackItem>
|
|
||||||
<Text>{t("selectIfResourceExists")}:</Text>
|
const last = Math.min(first + max, importResponse.results.length);
|
||||||
<Select
|
|
||||||
isOpen={isCollisionSelectOpen}
|
return importResponse.results.slice(first, last);
|
||||||
direction="up"
|
};
|
||||||
onToggle={() => {
|
|
||||||
setIsCollisionSelectOpen(!isCollisionSelectOpen);
|
const ActionLabel = (importRecord: PartialImportResult) => {
|
||||||
}}
|
switch (importRecord.action) {
|
||||||
onSelect={handleCollisionSelect}
|
case "ADDED":
|
||||||
placeholderText={t(collisionOption)}
|
return (
|
||||||
>
|
<Label key={importRecord.id} color="green">
|
||||||
{collisionOptions()}
|
{t("added")}
|
||||||
</Select>
|
</Label>
|
||||||
</StackItem>
|
);
|
||||||
</>
|
case "SKIPPED":
|
||||||
)}
|
return (
|
||||||
</Stack>
|
<Label key={importRecord.id} color="orange">
|
||||||
</Modal>
|
{t("skipped")}
|
||||||
);
|
</Label>
|
||||||
|
);
|
||||||
|
case "OVERWRITTEN":
|
||||||
|
return (
|
||||||
|
<Label key={importRecord.id} color="purple">
|
||||||
|
{t("overwritten")}
|
||||||
|
</Label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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 <span>{typeMap.get(importRecord.resourceType)}</span>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const importCompletedModal = () => {
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
variant={ModalVariant.medium}
|
||||||
|
title={tRealm("partialImport")}
|
||||||
|
isOpen={props.open}
|
||||||
|
onClose={props.toggleDialog}
|
||||||
|
actions={[
|
||||||
|
<Button
|
||||||
|
id="modal-close"
|
||||||
|
data-testid="close-button"
|
||||||
|
key="close"
|
||||||
|
variant={ButtonVariant.primary}
|
||||||
|
onClick={() => {
|
||||||
|
props.toggleDialog();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("common:close")}
|
||||||
|
</Button>,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Alert variant="success" isInline title={importCompleteMessage()} />
|
||||||
|
<KeycloakDataTable
|
||||||
|
loader={loader}
|
||||||
|
isPaginated
|
||||||
|
ariaLabelKey="realm-settings:partialImport"
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
name: "action",
|
||||||
|
displayKey: "common:action",
|
||||||
|
cellRenderer: ActionLabel,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "resourceType",
|
||||||
|
displayKey: "common:type",
|
||||||
|
cellRenderer: TypeRenderer,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "resourceName",
|
||||||
|
displayKey: "common:name",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "id",
|
||||||
|
displayKey: "common:id",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!importResponse) {
|
||||||
|
return importModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
return importCompletedModal();
|
||||||
};
|
};
|
||||||
|
|
|
@ -743,9 +743,23 @@ export default {
|
||||||
selectIfResourceExists:
|
selectIfResourceExists:
|
||||||
"If a resource already exists, specify what should be done",
|
"If a resource already exists, specify what should be done",
|
||||||
import: "Import",
|
import: "Import",
|
||||||
|
resourcesToImport: "Resources to import",
|
||||||
|
importFail: "Import failed: {{error}}",
|
||||||
FAIL: "Fail import",
|
FAIL: "Fail import",
|
||||||
SKIP: "Skip",
|
SKIP: "Skip",
|
||||||
OVERWRITE: "Overwrite",
|
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": {
|
"partial-export": {
|
||||||
partialExportHeaderText:
|
partialExportHeaderText:
|
||||||
|
|
Loading…
Reference in a new issue