Add overview for attributes groups to realm settings (#1822)
This commit is contained in:
parent
439212c4b5
commit
ee29a308e8
7 changed files with 241 additions and 11 deletions
54
cypress/integration/user_profile_tab.spec.ts
Normal file
54
cypress/integration/user_profile_tab.spec.ts
Normal file
|
@ -0,0 +1,54 @@
|
||||||
|
import ListingPage from "../support/pages/admin_console/ListingPage";
|
||||||
|
import RealmSettingsPage from "../support/pages/admin_console/manage/realm_settings/RealmSettingsPage";
|
||||||
|
import SidebarPage from "../support/pages/admin_console/SidebarPage";
|
||||||
|
import LoginPage from "../support/pages/LoginPage";
|
||||||
|
import AdminClient from "../support/util/AdminClient";
|
||||||
|
import { keycloakBefore } from "../support/util/keycloak_before";
|
||||||
|
import ModalUtils from "../support/util/ModalUtils";
|
||||||
|
|
||||||
|
const loginPage = new LoginPage();
|
||||||
|
const sidebarPage = new SidebarPage();
|
||||||
|
const realmSettingsPage = new RealmSettingsPage();
|
||||||
|
const adminClient = new AdminClient();
|
||||||
|
const listingPage = new ListingPage();
|
||||||
|
const modalUtils = new ModalUtils();
|
||||||
|
|
||||||
|
// Selectors
|
||||||
|
const getUserProfileTab = () =>
|
||||||
|
cy.findByTestId(realmSettingsPage.userProfileTab);
|
||||||
|
const getAttributesGroupTab = () => cy.findByTestId("attributesGroupTab");
|
||||||
|
|
||||||
|
describe("User profile tabs", () => {
|
||||||
|
const realmName = "Realm_" + (Math.random() + 1).toString(36).substring(7);
|
||||||
|
|
||||||
|
before(() =>
|
||||||
|
adminClient.createRealm(realmName, {
|
||||||
|
attributes: { userProfileEnabled: "true" },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
after(() => adminClient.deleteRealm(realmName));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
keycloakBefore();
|
||||||
|
loginPage.logIn();
|
||||||
|
sidebarPage.goToRealm(realmName);
|
||||||
|
sidebarPage.goToRealmSettings();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Attribute groups", () => {
|
||||||
|
it("deletes an attributes group", () => {
|
||||||
|
cy.wrap(null).then(() =>
|
||||||
|
adminClient.patchUserProfile(realmName, {
|
||||||
|
groups: [{ name: "Test" }],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
getUserProfileTab().click();
|
||||||
|
getAttributesGroupTab().click();
|
||||||
|
listingPage.deleteItem("Test");
|
||||||
|
modalUtils.confirmModal();
|
||||||
|
listingPage.itemExist("Test", false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -2,6 +2,9 @@ import KeycloakAdminClient from "@keycloak/keycloak-admin-client";
|
||||||
import type UserRepresentation from "@keycloak/keycloak-admin-client/lib/defs/userRepresentation";
|
import type UserRepresentation from "@keycloak/keycloak-admin-client/lib/defs/userRepresentation";
|
||||||
import type ClientRepresentation from "@keycloak/keycloak-admin-client/lib/defs/clientRepresentation";
|
import type ClientRepresentation from "@keycloak/keycloak-admin-client/lib/defs/clientRepresentation";
|
||||||
import type ClientScopeRepresentation from "@keycloak/keycloak-admin-client/lib/defs/clientScopeRepresentation";
|
import type ClientScopeRepresentation from "@keycloak/keycloak-admin-client/lib/defs/clientScopeRepresentation";
|
||||||
|
import type RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation";
|
||||||
|
import type UserProfileConfig from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig";
|
||||||
|
import { merge } from "lodash";
|
||||||
|
|
||||||
export default class AdminClient {
|
export default class AdminClient {
|
||||||
private client: KeycloakAdminClient;
|
private client: KeycloakAdminClient;
|
||||||
|
@ -21,9 +24,14 @@ export default class AdminClient {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async createRealm(realm: string) {
|
async createRealm(realm: string, payload?: RealmRepresentation) {
|
||||||
await this.login();
|
await this.login();
|
||||||
await this.client.realms.create({ realm });
|
await this.client.realms.create({ realm, ...payload });
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateRealm(realm: string, payload: RealmRepresentation) {
|
||||||
|
await this.login();
|
||||||
|
await this.client.realms.update({ realm }, payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteRealm(realm: string) {
|
async deleteRealm(realm: string) {
|
||||||
|
@ -129,4 +137,14 @@ export default class AdminClient {
|
||||||
clientScopeId: scope?.id!,
|
clientScopeId: scope?.id!,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async patchUserProfile(realm: string, payload: UserProfileConfig) {
|
||||||
|
await this.login();
|
||||||
|
|
||||||
|
const currentProfile = await this.client.users.getProfile({ realm });
|
||||||
|
|
||||||
|
await this.client.users.updateProfile(
|
||||||
|
merge(currentProfile, payload, { realm })
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -33,6 +33,7 @@ export default {
|
||||||
clearFileExplain: "Are you sure you want to clear this file?",
|
clearFileExplain: "Are you sure you want to clear this file?",
|
||||||
on: "On",
|
on: "On",
|
||||||
off: "Off",
|
off: "Off",
|
||||||
|
edit: "Edit",
|
||||||
enabled: "Enabled",
|
enabled: "Enabled",
|
||||||
disabled: "Disabled",
|
disabled: "Disabled",
|
||||||
disable: "Disable",
|
disable: "Disable",
|
||||||
|
|
|
@ -781,4 +781,19 @@ export default {
|
||||||
exportSuccess: "Realm successfully exported.",
|
exportSuccess: "Realm successfully exported.",
|
||||||
exportFail: "Could not export realm: '{{error}}'",
|
exportFail: "Could not export realm: '{{error}}'",
|
||||||
},
|
},
|
||||||
|
"attributes-group": {
|
||||||
|
createButtonText: "Create attributes group",
|
||||||
|
tableTitle: "Attributes groups",
|
||||||
|
columnName: "Name",
|
||||||
|
columnDisplayName: "Display name",
|
||||||
|
columnDisplayDescription: "Display description",
|
||||||
|
emptyStateMessage: "No attributes groups",
|
||||||
|
emptyStateInstructions:
|
||||||
|
"If you want to add an attributes group click the button below.",
|
||||||
|
deleteDialogTitle: "Delete attribute group?",
|
||||||
|
deleteDialogDescription:
|
||||||
|
"Are you sure you want to permanently delete the attributes group <1>{{group}}</1>?",
|
||||||
|
deleteSuccess: "Attributes group deleted.",
|
||||||
|
deleteError: "Could not delete user attributes group: {{error}}",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
120
src/realm-settings/user-profile/AttributesGroupTab.tsx
Normal file
120
src/realm-settings/user-profile/AttributesGroupTab.tsx
Normal file
|
@ -0,0 +1,120 @@
|
||||||
|
import type UserProfileConfig from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig";
|
||||||
|
import type { UserProfileGroup } from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
ButtonVariant,
|
||||||
|
PageSection,
|
||||||
|
ToolbarItem,
|
||||||
|
} from "@patternfly/react-core";
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
|
import { Link, useHistory } from "react-router-dom";
|
||||||
|
import { useConfirmDialog } from "../../components/confirm-dialog/ConfirmDialog";
|
||||||
|
import { ListEmptyState } from "../../components/list-empty-state/ListEmptyState";
|
||||||
|
import { KeycloakDataTable } from "../../components/table-toolbar/KeycloakDataTable";
|
||||||
|
import type { OnSaveCallback } from "./UserProfileTab";
|
||||||
|
|
||||||
|
type AttributesGroupTabProps = {
|
||||||
|
config?: UserProfileConfig;
|
||||||
|
onSave: OnSaveCallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AttributesGroupTab = ({
|
||||||
|
config,
|
||||||
|
onSave,
|
||||||
|
}: AttributesGroupTabProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const history = useHistory();
|
||||||
|
const [key, setKey] = useState(0);
|
||||||
|
const [groupToDelete, setGroupToDelete] = useState<UserProfileGroup>();
|
||||||
|
|
||||||
|
// Refresh data in table when config changes.
|
||||||
|
useEffect(() => setKey((value) => value + 1), [config]);
|
||||||
|
|
||||||
|
async function loader() {
|
||||||
|
return config?.groups ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const [toggleDeleteDialog, DeleteConfirm] = useConfirmDialog({
|
||||||
|
titleKey: "attributes-group:deleteDialogTitle",
|
||||||
|
children: (
|
||||||
|
<Trans i18nKey="attributes-group:deleteDialogDescription">
|
||||||
|
{" "}
|
||||||
|
<strong>{{ group: groupToDelete?.name }}</strong>.
|
||||||
|
</Trans>
|
||||||
|
),
|
||||||
|
continueButtonLabel: "common:delete",
|
||||||
|
continueButtonVariant: ButtonVariant.danger,
|
||||||
|
onConfirm() {
|
||||||
|
const groups = (config?.groups ?? []).filter(
|
||||||
|
(group) => group !== groupToDelete
|
||||||
|
);
|
||||||
|
|
||||||
|
onSave(
|
||||||
|
{ ...config, groups },
|
||||||
|
{
|
||||||
|
successMessageKey: "attributes-group:deleteSuccess",
|
||||||
|
errorMessageKey: "attributes-group:deleteError",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function deleteAttributeGroup(group: UserProfileGroup) {
|
||||||
|
setGroupToDelete(group);
|
||||||
|
toggleDeleteDialog();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageSection variant="light" className="pf-u-p-0">
|
||||||
|
<DeleteConfirm />
|
||||||
|
<KeycloakDataTable
|
||||||
|
key={key}
|
||||||
|
loader={loader}
|
||||||
|
ariaLabelKey="attributes-group:tableTitle"
|
||||||
|
toolbarItem={
|
||||||
|
<ToolbarItem>
|
||||||
|
{/* TODO: Add link to page */}
|
||||||
|
<Button component={(props) => <Link {...props} to={{}} />}>
|
||||||
|
{t("attributes-group:createButtonText")}
|
||||||
|
</Button>
|
||||||
|
</ToolbarItem>
|
||||||
|
}
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
name: "name",
|
||||||
|
displayKey: "attributes-group:columnName",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "displayHeader",
|
||||||
|
displayKey: "attributes-group:columnDisplayName",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "displayDescription",
|
||||||
|
displayKey: "attributes-group:columnDisplayDescription",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
title: t("common:edit"),
|
||||||
|
// TODO: Add link to page.
|
||||||
|
onRowClick: () => history.push({}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("common:delete"),
|
||||||
|
onRowClick: deleteAttributeGroup,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
emptyState={
|
||||||
|
<ListEmptyState
|
||||||
|
message={t("attributes-group:emptyStateMessage")}
|
||||||
|
instructions={t("attributes-group:emptyStateInstructions")}
|
||||||
|
primaryActionText={t("attributes-group:createButtonText")}
|
||||||
|
// TODO: Add link to page.
|
||||||
|
onPrimaryAction={() => history.push({})}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</PageSection>
|
||||||
|
);
|
||||||
|
};
|
|
@ -1,4 +1,3 @@
|
||||||
import type ClientProfilesRepresentation from "@keycloak/keycloak-admin-client/lib/defs/clientProfilesRepresentation";
|
|
||||||
import type UserProfileConfig from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig";
|
import type UserProfileConfig from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig";
|
||||||
import { CodeEditor, Language } from "@patternfly/react-code-editor";
|
import { CodeEditor, Language } from "@patternfly/react-code-editor";
|
||||||
import { ActionGroup, Button, Form, PageSection } from "@patternfly/react-core";
|
import { ActionGroup, Button, Form, PageSection } from "@patternfly/react-core";
|
||||||
|
@ -7,10 +6,11 @@ import React, { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useAlerts } from "../../components/alert/Alerts";
|
import { useAlerts } from "../../components/alert/Alerts";
|
||||||
import { prettyPrintJSON } from "../../util";
|
import { prettyPrintJSON } from "../../util";
|
||||||
|
import type { OnSaveCallback } from "./UserProfileTab";
|
||||||
|
|
||||||
type JsonEditorTabProps = {
|
type JsonEditorTabProps = {
|
||||||
config?: UserProfileConfig;
|
config?: UserProfileConfig;
|
||||||
onSave: (profiles: ClientProfilesRepresentation) => void;
|
onSave: OnSaveCallback;
|
||||||
isSaving: boolean;
|
isSaving: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
import type ClientProfilesRepresentation from "@keycloak/keycloak-admin-client/lib/defs/clientProfilesRepresentation";
|
|
||||||
import type UserProfileConfig from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig";
|
import type UserProfileConfig from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig";
|
||||||
import { AlertVariant, Tab, Tabs, TabTitleText } from "@patternfly/react-core";
|
import { AlertVariant, Tab, Tabs, TabTitleText } from "@patternfly/react-core";
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
|
@ -6,8 +5,19 @@ import { useTranslation } from "react-i18next";
|
||||||
import { useAlerts } from "../../components/alert/Alerts";
|
import { useAlerts } from "../../components/alert/Alerts";
|
||||||
import { useAdminClient, useFetch } from "../../context/auth/AdminClient";
|
import { useAdminClient, useFetch } from "../../context/auth/AdminClient";
|
||||||
import { useRealm } from "../../context/realm-context/RealmContext";
|
import { useRealm } from "../../context/realm-context/RealmContext";
|
||||||
|
import { AttributesGroupTab } from "./AttributesGroupTab";
|
||||||
import { JsonEditorTab } from "./JsonEditorTab";
|
import { JsonEditorTab } from "./JsonEditorTab";
|
||||||
|
|
||||||
|
export type OnSaveCallback = (
|
||||||
|
updatedProfiles: UserProfileConfig,
|
||||||
|
options?: OnSaveOptions
|
||||||
|
) => Promise<void>;
|
||||||
|
|
||||||
|
export type OnSaveOptions = {
|
||||||
|
successMessageKey?: string;
|
||||||
|
errorMessageKey?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export const UserProfileTab = () => {
|
export const UserProfileTab = () => {
|
||||||
const adminClient = useAdminClient();
|
const adminClient = useAdminClient();
|
||||||
const { realm } = useRealm();
|
const { realm } = useRealm();
|
||||||
|
@ -24,23 +34,32 @@ export const UserProfileTab = () => {
|
||||||
[refreshCount]
|
[refreshCount]
|
||||||
);
|
);
|
||||||
|
|
||||||
async function onSave(updatedProfiles: ClientProfilesRepresentation) {
|
const onSave: OnSaveCallback = async (
|
||||||
|
updatedProfiles: UserProfileConfig,
|
||||||
|
options?: OnSaveOptions
|
||||||
|
) => {
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await adminClient.clientPolicies.createProfiles({
|
await adminClient.users.updateProfile({
|
||||||
...updatedProfiles,
|
...updatedProfiles,
|
||||||
realm,
|
realm,
|
||||||
});
|
});
|
||||||
|
|
||||||
setRefreshCount(refreshCount + 1);
|
setRefreshCount(refreshCount + 1);
|
||||||
addAlert(t("userProfileSuccess"), AlertVariant.success);
|
addAlert(
|
||||||
|
t(options?.successMessageKey ?? "userProfileSuccess"),
|
||||||
|
AlertVariant.success
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
addError("realm-settings:userProfileError", error);
|
addError(
|
||||||
|
options?.errorMessageKey ?? "realm-settings:userProfileError",
|
||||||
|
error
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tabs
|
<Tabs
|
||||||
|
@ -55,7 +74,10 @@ export const UserProfileTab = () => {
|
||||||
<Tab
|
<Tab
|
||||||
eventKey="attributesGroup"
|
eventKey="attributesGroup"
|
||||||
title={<TabTitleText>{t("attributesGroup")}</TabTitleText>}
|
title={<TabTitleText>{t("attributesGroup")}</TabTitleText>}
|
||||||
></Tab>
|
data-testid="attributesGroupTab"
|
||||||
|
>
|
||||||
|
<AttributesGroupTab config={config} onSave={onSave} />
|
||||||
|
</Tab>
|
||||||
<Tab
|
<Tab
|
||||||
eventKey="jsonEditor"
|
eventKey="jsonEditor"
|
||||||
title={<TabTitleText>{t("jsonEditor")}</TabTitleText>}
|
title={<TabTitleText>{t("jsonEditor")}</TabTitleText>}
|
||||||
|
|
Loading…
Reference in a new issue