Initial version of the create and edit resouce screen (#1573)
* Initial version of the create and edit resouce screen * refactored and fixed the attributes form introduced a new component that can be used more easily * Update src/components/json-file-upload/FileUploadForm.tsx Co-authored-by: Jon Koops <jonkoops@gmail.com> * Update src/components/attribute-form/attribute-convert.ts Co-authored-by: Jon Koops <jonkoops@gmail.com> * Update src/clients/authorization/ResourceDetails.tsx Co-authored-by: Jon Koops <jonkoops@gmail.com> * Update src/clients/authorization/ResourceDetails.tsx Co-authored-by: Jon Koops <jonkoops@gmail.com> * Update src/clients/authorization/ResourceDetails.tsx Co-authored-by: Jon Koops <jonkoops@gmail.com> * PR review * fixed tests * PR review comments * resourceId is optional * Update src/components/attribute-form/attribute-convert.ts Co-authored-by: Jon Koops <jonkoops@gmail.com> Co-authored-by: Jon Koops <jonkoops@gmail.com>
This commit is contained in:
parent
93ee81b6af
commit
0bbd4ddad1
26 changed files with 1010 additions and 301 deletions
|
@ -10,6 +10,7 @@ import InitialAccessTokenTab from "../support/pages/admin_console/manage/clients
|
||||||
import { keycloakBefore } from "../support/util/keycloak_before";
|
import { keycloakBefore } from "../support/util/keycloak_before";
|
||||||
import RoleMappingTab from "../support/pages/admin_console/manage/RoleMappingTab";
|
import RoleMappingTab from "../support/pages/admin_console/manage/RoleMappingTab";
|
||||||
import KeysTab from "../support/pages/admin_console/manage/clients/KeysTab";
|
import KeysTab from "../support/pages/admin_console/manage/clients/KeysTab";
|
||||||
|
import AuthenticationTab from "../support/pages/admin_console/manage/clients/Authentication";
|
||||||
|
|
||||||
let itemId = "client_crud";
|
let itemId = "client_crud";
|
||||||
const loginPage = new LoginPage();
|
const loginPage = new LoginPage();
|
||||||
|
@ -426,4 +427,52 @@ describe("Clients test", () => {
|
||||||
cy.findAllByTestId("certificate").should("have.length", 1);
|
cy.findAllByTestId("certificate").should("have.length", 1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Authentication tab", () => {
|
||||||
|
const clientName = "authenticationTabClient";
|
||||||
|
const authenticationTab = new AuthenticationTab();
|
||||||
|
beforeEach(() => {
|
||||||
|
keycloakBefore();
|
||||||
|
loginPage.logIn();
|
||||||
|
sidebarPage.goToClients();
|
||||||
|
});
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
await new AdminClient().createClient({
|
||||||
|
protocol: "openid-connect",
|
||||||
|
clientId: clientName,
|
||||||
|
publicClient: false,
|
||||||
|
authorizationServicesEnabled: true,
|
||||||
|
serviceAccountsEnabled: true,
|
||||||
|
standardFlowEnabled: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
after(() => {
|
||||||
|
new AdminClient().deleteClient(clientName);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Should update the resource server settings", () => {
|
||||||
|
listingPage.searchItem(clientName).goToItemDetails(clientName);
|
||||||
|
authenticationTab.goToTab();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Should create a resource", () => {
|
||||||
|
listingPage.searchItem(clientName).goToItemDetails(clientName);
|
||||||
|
authenticationTab.goToTab().goToResourceSubTab();
|
||||||
|
authenticationTab.assertDefaultResource();
|
||||||
|
|
||||||
|
authenticationTab
|
||||||
|
.goToCreateResource()
|
||||||
|
.fillResourceForm({
|
||||||
|
name: "Resource",
|
||||||
|
displayName: "The display name",
|
||||||
|
type: "type",
|
||||||
|
uris: ["one", "two"],
|
||||||
|
})
|
||||||
|
.save();
|
||||||
|
|
||||||
|
masthead.checkNotificationMessage("Resource created successfully");
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -146,18 +146,16 @@ describe("Users test", () => {
|
||||||
const attributeKey = "key-multiple";
|
const attributeKey = "key-multiple";
|
||||||
attributesTab
|
attributesTab
|
||||||
.goToAttributesTab()
|
.goToAttributesTab()
|
||||||
.fillLastRow(attributeKey, "value")
|
|
||||||
.addRow()
|
|
||||||
.fillLastRow(attributeKey, "other value")
|
.fillLastRow(attributeKey, "other value")
|
||||||
.saveAttribute();
|
.saveAttribute();
|
||||||
|
|
||||||
cy.wait("@save-user").should(({ request, response }) => {
|
cy.wait("@save-user").should(({ request, response }) => {
|
||||||
expect(response?.statusCode).to.equal(204);
|
expect(response?.statusCode).to.equal(204);
|
||||||
|
|
||||||
expect(
|
expect(request?.body.attributes, "response body").deep.equal({
|
||||||
request?.body.attributes[attributeKey],
|
key: ["value"],
|
||||||
"response body"
|
"key-multiple": ["other value"],
|
||||||
).deep.equal(["value", "other value"]);
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
masthead.checkNotificationMessage("The user has been saved");
|
masthead.checkNotificationMessage("The user has been saved");
|
||||||
|
|
|
@ -0,0 +1,66 @@
|
||||||
|
import type ResourceRepresentation from "@keycloak/keycloak-admin-client/lib/defs/resourceRepresentation";
|
||||||
|
|
||||||
|
export default class AuthenticationTab {
|
||||||
|
private tabName = "#pf-tab-authorization-authorization";
|
||||||
|
private resourcesTabName = "#pf-tab-41-resources";
|
||||||
|
private nameColumnPrefix = "name-column-";
|
||||||
|
private createResourceButton = "createResource";
|
||||||
|
|
||||||
|
goToTab() {
|
||||||
|
cy.get(this.tabName).click();
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
goToResourceSubTab() {
|
||||||
|
cy.get(this.resourcesTabName).click();
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
goToCreateResource() {
|
||||||
|
cy.findAllByTestId(this.createResourceButton).click();
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
fillResourceForm(resource: ResourceRepresentation) {
|
||||||
|
Object.entries(resource).map(([key, value]) => {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
for (let index = 0; index < value.length; index++) {
|
||||||
|
const v = value[index];
|
||||||
|
cy.get(`input[name="${key}[${index}].value"]`).type(v);
|
||||||
|
cy.findByTestId("addValue").click();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cy.get(`#${key}`).type(value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
save() {
|
||||||
|
cy.findByTestId("save").click();
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
pressCancel() {
|
||||||
|
cy.findAllByTestId("cancel").click();
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getResourceLink(name: string) {
|
||||||
|
return cy.findByTestId(this.nameColumnPrefix + name);
|
||||||
|
}
|
||||||
|
|
||||||
|
goToResourceDetails(name: string) {
|
||||||
|
this.getResourceLink(name).click();
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
assertDefaultResource() {
|
||||||
|
return this.assertResource("Default Resource");
|
||||||
|
}
|
||||||
|
|
||||||
|
assertResource(name: string) {
|
||||||
|
this.getResourceLink(name).should("exist");
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
}
|
|
@ -34,11 +34,8 @@ export default class CreateUserPage {
|
||||||
}
|
}
|
||||||
|
|
||||||
goToCreateUser() {
|
goToCreateUser() {
|
||||||
cy.wait(100);
|
|
||||||
cy.get("body").then((body) => {
|
cy.get("body").then((body) => {
|
||||||
if (body.find("[data-testid=empty-state]").length > 0) {
|
if (body.find("[data-testid=search-users-title]").length > 0) {
|
||||||
cy.findByTestId(this.emptyStateCreateUserBtn).click();
|
|
||||||
} else if (body.find("[data-testid=search-users-title]").length > 0) {
|
|
||||||
cy.findByTestId(this.searchPgCreateUserBtn).click();
|
cy.findByTestId(this.searchPgCreateUserBtn).click();
|
||||||
} else {
|
} else {
|
||||||
cy.findByTestId(this.addUserBtn).click();
|
cy.findByTestId(this.addUserBtn).click();
|
||||||
|
|
|
@ -8,6 +8,7 @@ import {
|
||||||
} from "@patternfly/react-core";
|
} from "@patternfly/react-core";
|
||||||
|
|
||||||
import type ResourceServerRepresentation from "@keycloak/keycloak-admin-client/lib/defs/resourceServerRepresentation";
|
import type ResourceServerRepresentation from "@keycloak/keycloak-admin-client/lib/defs/resourceServerRepresentation";
|
||||||
|
import { KeycloakSpinner } from "../../components/keycloak-spinner/KeycloakSpinner";
|
||||||
import { useAdminClient, useFetch } from "../../context/auth/AdminClient";
|
import { useAdminClient, useFetch } from "../../context/auth/AdminClient";
|
||||||
|
|
||||||
import "./detail-cell.css";
|
import "./detail-cell.css";
|
||||||
|
@ -45,9 +46,13 @@ export const DetailCell = ({ id, clientId, uris }: DetailCellProps) => {
|
||||||
},
|
},
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!permissions || !scope) {
|
||||||
|
return <KeycloakSpinner />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DescriptionList isHorizontal className="keycloak_resource_details">
|
<DescriptionList isHorizontal className="keycloak_resource_details">
|
||||||
{uris?.length !== 0 && (
|
|
||||||
<DescriptionListGroup>
|
<DescriptionListGroup>
|
||||||
<DescriptionListTerm>{t("uris")}</DescriptionListTerm>
|
<DescriptionListTerm>{t("uris")}</DescriptionListTerm>
|
||||||
<DescriptionListDescription>
|
<DescriptionListDescription>
|
||||||
|
@ -56,35 +61,31 @@ export const DetailCell = ({ id, clientId, uris }: DetailCellProps) => {
|
||||||
{uri}
|
{uri}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
|
{uris?.length === 0 && <i>{t("common:none")}</i>}
|
||||||
</DescriptionListDescription>
|
</DescriptionListDescription>
|
||||||
</DescriptionListGroup>
|
</DescriptionListGroup>
|
||||||
)}
|
|
||||||
{scope?.length !== 0 && (
|
|
||||||
<DescriptionListGroup>
|
<DescriptionListGroup>
|
||||||
<DescriptionListTerm>{t("scopes")}</DescriptionListTerm>
|
<DescriptionListTerm>{t("scopes")}</DescriptionListTerm>
|
||||||
<DescriptionListDescription>
|
<DescriptionListDescription>
|
||||||
{scope?.map((scope) => (
|
{scope.map((scope) => (
|
||||||
<span key={scope.id} className="pf-u-pr-sm">
|
<span key={scope.id} className="pf-u-pr-sm">
|
||||||
{scope.name}
|
{scope.name}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
|
{scope.length === 0 && <i>{t("common:none")}</i>}
|
||||||
</DescriptionListDescription>
|
</DescriptionListDescription>
|
||||||
</DescriptionListGroup>
|
</DescriptionListGroup>
|
||||||
)}
|
|
||||||
{permissions?.length !== 0 && (
|
|
||||||
<DescriptionListGroup>
|
<DescriptionListGroup>
|
||||||
<DescriptionListTerm>
|
<DescriptionListTerm>{t("associatedPermissions")}</DescriptionListTerm>
|
||||||
{t("associatedPermissions")}
|
|
||||||
</DescriptionListTerm>
|
|
||||||
<DescriptionListDescription>
|
<DescriptionListDescription>
|
||||||
{permissions?.map((permission) => (
|
{permissions.map((permission) => (
|
||||||
<span key={permission.id} className="pf-u-pr-sm">
|
<span key={permission.id} className="pf-u-pr-sm">
|
||||||
{permission.name}
|
{permission.name}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
|
{permissions.length === 0 && <i>{t("common:none")}</i>}
|
||||||
</DescriptionListDescription>
|
</DescriptionListDescription>
|
||||||
</DescriptionListGroup>
|
</DescriptionListGroup>
|
||||||
)}
|
|
||||||
</DescriptionList>
|
</DescriptionList>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
388
src/clients/authorization/ResourceDetails.tsx
Normal file
388
src/clients/authorization/ResourceDetails.tsx
Normal file
|
@ -0,0 +1,388 @@
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { Link, useHistory, useParams } from "react-router-dom";
|
||||||
|
import { Controller, FormProvider, useForm } from "react-hook-form";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
ActionGroup,
|
||||||
|
Alert,
|
||||||
|
AlertVariant,
|
||||||
|
Button,
|
||||||
|
ButtonVariant,
|
||||||
|
DropdownItem,
|
||||||
|
FormGroup,
|
||||||
|
PageSection,
|
||||||
|
Switch,
|
||||||
|
TextInput,
|
||||||
|
ValidatedOptions,
|
||||||
|
} from "@patternfly/react-core";
|
||||||
|
|
||||||
|
import type ClientRepresentation from "@keycloak/keycloak-admin-client/lib/defs/clientRepresentation";
|
||||||
|
import type ResourceRepresentation from "@keycloak/keycloak-admin-client/lib/defs/resourceRepresentation";
|
||||||
|
import type ResourceServerRepresentation from "@keycloak/keycloak-admin-client/lib/defs/resourceServerRepresentation";
|
||||||
|
import { ResourceDetailsParams, toResourceDetails } from "../routes/Resource";
|
||||||
|
import { KeycloakSpinner } from "../../components/keycloak-spinner/KeycloakSpinner";
|
||||||
|
import { useAdminClient, useFetch } from "../../context/auth/AdminClient";
|
||||||
|
import { HelpItem } from "../../components/help-enabler/HelpItem";
|
||||||
|
import { ViewHeader } from "../../components/view-header/ViewHeader";
|
||||||
|
import { useConfirmDialog } from "../../components/confirm-dialog/ConfirmDialog";
|
||||||
|
import { useAlerts } from "../../components/alert/Alerts";
|
||||||
|
import { FormAccess } from "../../components/form-access/FormAccess";
|
||||||
|
import {
|
||||||
|
convertToMultiline,
|
||||||
|
MultiLine,
|
||||||
|
MultiLineInput,
|
||||||
|
toValue,
|
||||||
|
} from "../../components/multi-line-input/MultiLineInput";
|
||||||
|
import { toClient } from "../routes/Client";
|
||||||
|
import { ScopePicker } from "./ScopePicker";
|
||||||
|
import {
|
||||||
|
arrayToAttributes,
|
||||||
|
attributesToArray,
|
||||||
|
KeyValueType,
|
||||||
|
} from "../../components/attribute-form/attribute-convert";
|
||||||
|
import { AttributeInput } from "../../components/attribute-input/AttributeInput";
|
||||||
|
|
||||||
|
import "./resource-details.css";
|
||||||
|
|
||||||
|
type FetchResource = {
|
||||||
|
client?: ClientRepresentation;
|
||||||
|
resource?: ResourceRepresentation;
|
||||||
|
permissions?: ResourceServerRepresentation[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type SubmittedResource = Omit<ResourceRepresentation, "attributes" | "uris"> & {
|
||||||
|
attributes: KeyValueType[];
|
||||||
|
uris: MultiLine[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ResourceDetails() {
|
||||||
|
const { t } = useTranslation("clients");
|
||||||
|
const [client, setClient] = useState<ClientRepresentation>();
|
||||||
|
const [resource, setResource] = useState<ResourceRepresentation>();
|
||||||
|
|
||||||
|
const [permissions, setPermission] =
|
||||||
|
useState<ResourceServerRepresentation[]>();
|
||||||
|
|
||||||
|
const adminClient = useAdminClient();
|
||||||
|
const { addAlert, addError } = useAlerts();
|
||||||
|
const form = useForm<SubmittedResource>({
|
||||||
|
shouldUnregister: false,
|
||||||
|
mode: "onChange",
|
||||||
|
});
|
||||||
|
const { register, errors, control, setValue, handleSubmit } = form;
|
||||||
|
|
||||||
|
const { id, resourceId, realm } = useParams<ResourceDetailsParams>();
|
||||||
|
const history = useHistory();
|
||||||
|
|
||||||
|
const setupForm = (resource: ResourceRepresentation = {}) => {
|
||||||
|
Object.entries(resource).forEach(([key, value]) => {
|
||||||
|
if (key === "uris") {
|
||||||
|
setValue("uris", convertToMultiline(value));
|
||||||
|
} else if (key === "attributes") {
|
||||||
|
setValue("attributes", attributesToArray(value));
|
||||||
|
} else {
|
||||||
|
setValue(key, value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useFetch(
|
||||||
|
async (): Promise<FetchResource> => {
|
||||||
|
const [client, resource, permissions] = await Promise.all([
|
||||||
|
adminClient.clients.findOne({ id }),
|
||||||
|
resourceId
|
||||||
|
? adminClient.clients.getResource({ id, resourceId })
|
||||||
|
: Promise.resolve(undefined),
|
||||||
|
resourceId
|
||||||
|
? adminClient.clients.listPermissionsByResource({ id, resourceId })
|
||||||
|
: Promise.resolve(undefined),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { client, resource, permissions };
|
||||||
|
},
|
||||||
|
({ client, resource, permissions }) => {
|
||||||
|
if (!client) {
|
||||||
|
throw new Error(t("common:notFound"));
|
||||||
|
}
|
||||||
|
setClient(client);
|
||||||
|
setPermission(permissions);
|
||||||
|
setResource(resource);
|
||||||
|
setupForm(resource);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const save = async (submitted: SubmittedResource) => {
|
||||||
|
const { attributes, uris, ...rest } = submitted;
|
||||||
|
const resource = {
|
||||||
|
...rest,
|
||||||
|
attributes: arrayToAttributes(attributes),
|
||||||
|
uris: toValue(uris),
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (resourceId) {
|
||||||
|
await adminClient.clients.updateResource({ id, resourceId }, resource);
|
||||||
|
} else {
|
||||||
|
const result = await adminClient.clients.createResource(
|
||||||
|
{ id },
|
||||||
|
resource
|
||||||
|
);
|
||||||
|
history.push(toResourceDetails({ realm, id, resourceId: result._id! }));
|
||||||
|
}
|
||||||
|
addAlert(
|
||||||
|
t((resourceId ? "update" : "create") + "ResourceSuccess"),
|
||||||
|
AlertVariant.success
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
addError("client:resourceSaveError", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const [toggleDeleteDialog, DeleteConfirm] = useConfirmDialog({
|
||||||
|
titleKey: "clients:deleteResource",
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
{t("deleteResourceConfirm")}
|
||||||
|
{permissions?.length !== 0 && (
|
||||||
|
<Alert
|
||||||
|
variant="warning"
|
||||||
|
isInline
|
||||||
|
isPlain
|
||||||
|
title={t("deleteResourceWarning")}
|
||||||
|
className="pf-u-pt-lg"
|
||||||
|
>
|
||||||
|
<p className="pf-u-pt-xs">
|
||||||
|
{permissions?.map((permission) => (
|
||||||
|
<strong key={permission.id} className="pf-u-pr-md">
|
||||||
|
{permission.name}
|
||||||
|
</strong>
|
||||||
|
))}
|
||||||
|
</p>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
continueButtonLabel: "clients:confirm",
|
||||||
|
onConfirm: async () => {
|
||||||
|
try {
|
||||||
|
await adminClient.clients.delResource({
|
||||||
|
id,
|
||||||
|
resourceId: resourceId!,
|
||||||
|
});
|
||||||
|
addAlert(t("resourceDeletedSuccess"), AlertVariant.success);
|
||||||
|
history.push(toClient({ realm, clientId: id, tab: "authorization" }));
|
||||||
|
} catch (error) {
|
||||||
|
addError("clients:resourceDeletedError", error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!client) {
|
||||||
|
return <KeycloakSpinner />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DeleteConfirm />
|
||||||
|
<ViewHeader
|
||||||
|
titleKey={resourceId ? resource?.name! : "clients:createResource"}
|
||||||
|
dropdownItems={
|
||||||
|
resourceId
|
||||||
|
? [
|
||||||
|
<DropdownItem
|
||||||
|
key="delete"
|
||||||
|
data-testid="delete-resource"
|
||||||
|
onClick={() => toggleDeleteDialog()}
|
||||||
|
>
|
||||||
|
{t("common:delete")}
|
||||||
|
</DropdownItem>,
|
||||||
|
]
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<PageSection variant="light">
|
||||||
|
<FormProvider {...form}>
|
||||||
|
<FormAccess
|
||||||
|
isHorizontal
|
||||||
|
role="manage-clients"
|
||||||
|
className="keycloak__resource-details__form"
|
||||||
|
onSubmit={handleSubmit(save)}
|
||||||
|
>
|
||||||
|
<FormGroup
|
||||||
|
label={t("owner")}
|
||||||
|
fieldId="owner"
|
||||||
|
labelIcon={
|
||||||
|
<HelpItem
|
||||||
|
helpText="clients-help:owner"
|
||||||
|
forLabel={t("owner")}
|
||||||
|
forID={t(`common:helpLabel`, { label: t("owner") })}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<TextInput id="owner" value={client.clientId} isReadOnly />
|
||||||
|
</FormGroup>
|
||||||
|
<FormGroup
|
||||||
|
label={t("common:name")}
|
||||||
|
fieldId="name"
|
||||||
|
labelIcon={
|
||||||
|
<HelpItem
|
||||||
|
helpText="clients-help:resourceName"
|
||||||
|
forLabel={t("name")}
|
||||||
|
forID={t(`common:helpLabel`, { label: t("name") })}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
helperTextInvalid={t("common:required")}
|
||||||
|
validated={
|
||||||
|
errors.name ? ValidatedOptions.error : ValidatedOptions.default
|
||||||
|
}
|
||||||
|
isRequired
|
||||||
|
>
|
||||||
|
<TextInput
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
ref={register({ required: true })}
|
||||||
|
validated={
|
||||||
|
errors.name
|
||||||
|
? ValidatedOptions.error
|
||||||
|
: ValidatedOptions.default
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
<FormGroup
|
||||||
|
label={t("displayName")}
|
||||||
|
fieldId="displayName"
|
||||||
|
labelIcon={
|
||||||
|
<HelpItem
|
||||||
|
helpText="clients-help:displayName"
|
||||||
|
forLabel={t("name")}
|
||||||
|
forID={t(`common:helpLabel`, { label: t("name") })}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<TextInput id="displayName" name="name" ref={register} />
|
||||||
|
</FormGroup>
|
||||||
|
<FormGroup
|
||||||
|
label={t("type")}
|
||||||
|
fieldId="type"
|
||||||
|
labelIcon={
|
||||||
|
<HelpItem
|
||||||
|
helpText="clients-help:type"
|
||||||
|
forLabel={t("type")}
|
||||||
|
forID={t(`common:helpLabel`, { label: t("type") })}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<TextInput id="type" name="type" ref={register} />
|
||||||
|
</FormGroup>
|
||||||
|
<FormGroup
|
||||||
|
label={t("uris")}
|
||||||
|
fieldId="uris"
|
||||||
|
labelIcon={
|
||||||
|
<HelpItem
|
||||||
|
helpText="clients-help:uris"
|
||||||
|
forLabel={t("uris")}
|
||||||
|
forID={t(`common:helpLabel`, { label: t("uris") })}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<MultiLineInput
|
||||||
|
name="uris"
|
||||||
|
aria-label={t("uri")}
|
||||||
|
addButtonLabel="clients:addUri"
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
<ScopePicker clientId={id} />
|
||||||
|
<FormGroup
|
||||||
|
label={t("iconUri")}
|
||||||
|
fieldId="iconUri"
|
||||||
|
labelIcon={
|
||||||
|
<HelpItem
|
||||||
|
helpText="clients-help:iconUri"
|
||||||
|
forLabel={t("iconUri")}
|
||||||
|
forID={t(`common:helpLabel`, { label: t("iconUri") })}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<TextInput id="iconUri" name="icon_uri" ref={register} />
|
||||||
|
</FormGroup>
|
||||||
|
<FormGroup
|
||||||
|
hasNoPaddingTop
|
||||||
|
label={t("ownerManagedAccess")}
|
||||||
|
labelIcon={
|
||||||
|
<HelpItem
|
||||||
|
helpText="clients-help:ownerManagedAccess"
|
||||||
|
forLabel={t("ownerManagedAccess")}
|
||||||
|
forID={t(`common:helpLabel`, {
|
||||||
|
label: t("ownerManagedAccess"),
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
fieldId="ownerManagedAccess"
|
||||||
|
>
|
||||||
|
<Controller
|
||||||
|
name="ownerManagedAccess"
|
||||||
|
control={control}
|
||||||
|
defaultValue={false}
|
||||||
|
render={({ onChange, value }) => (
|
||||||
|
<Switch
|
||||||
|
id="ownerManagedAccess"
|
||||||
|
label={t("common:on")}
|
||||||
|
labelOff={t("common:off")}
|
||||||
|
isChecked={value}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
|
||||||
|
<FormGroup
|
||||||
|
hasNoPaddingTop
|
||||||
|
label={t("resourceAttribute")}
|
||||||
|
labelIcon={
|
||||||
|
<HelpItem
|
||||||
|
helpText="clients-help:resourceAttribute"
|
||||||
|
forLabel={t("resourceAttribute")}
|
||||||
|
forID={t(`common:helpLabel`, {
|
||||||
|
label: t("resourceAttribute"),
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
fieldId="resourceAttribute"
|
||||||
|
>
|
||||||
|
<AttributeInput name="attributes" />
|
||||||
|
</FormGroup>
|
||||||
|
<ActionGroup>
|
||||||
|
<div className="pf-u-mt-md">
|
||||||
|
<Button
|
||||||
|
variant={ButtonVariant.primary}
|
||||||
|
type="submit"
|
||||||
|
data-testid="save"
|
||||||
|
>
|
||||||
|
{t("common:save")}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="link"
|
||||||
|
data-testid="cancel"
|
||||||
|
component={(props) => (
|
||||||
|
<Link
|
||||||
|
{...props}
|
||||||
|
to={toClient({
|
||||||
|
realm,
|
||||||
|
clientId: id,
|
||||||
|
tab: "authorization",
|
||||||
|
})}
|
||||||
|
></Link>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t("common:cancel")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</ActionGroup>
|
||||||
|
</FormAccess>
|
||||||
|
</FormProvider>
|
||||||
|
</PageSection>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,11 +1,13 @@
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
AlertVariant,
|
AlertVariant,
|
||||||
|
Button,
|
||||||
Label,
|
Label,
|
||||||
PageSection,
|
PageSection,
|
||||||
Spinner,
|
ToolbarItem,
|
||||||
} from "@patternfly/react-core";
|
} from "@patternfly/react-core";
|
||||||
import {
|
import {
|
||||||
ExpandableRowContent,
|
ExpandableRowContent,
|
||||||
|
@ -19,11 +21,15 @@ import {
|
||||||
|
|
||||||
import type ResourceServerRepresentation from "@keycloak/keycloak-admin-client/lib/defs/resourceServerRepresentation";
|
import type ResourceServerRepresentation from "@keycloak/keycloak-admin-client/lib/defs/resourceServerRepresentation";
|
||||||
import type ResourceRepresentation from "@keycloak/keycloak-admin-client/lib/defs/resourceRepresentation";
|
import type ResourceRepresentation from "@keycloak/keycloak-admin-client/lib/defs/resourceRepresentation";
|
||||||
|
import { KeycloakSpinner } from "../../components/keycloak-spinner/KeycloakSpinner";
|
||||||
import { useConfirmDialog } from "../../components/confirm-dialog/ConfirmDialog";
|
import { useConfirmDialog } from "../../components/confirm-dialog/ConfirmDialog";
|
||||||
import { PaginatingTableToolbar } from "../../components/table-toolbar/PaginatingTableToolbar";
|
import { PaginatingTableToolbar } from "../../components/table-toolbar/PaginatingTableToolbar";
|
||||||
import { useAdminClient, useFetch } from "../../context/auth/AdminClient";
|
import { useAdminClient, useFetch } from "../../context/auth/AdminClient";
|
||||||
import { useAlerts } from "../../components/alert/Alerts";
|
import { useAlerts } from "../../components/alert/Alerts";
|
||||||
import { DetailCell } from "./DetailCell";
|
import { DetailCell } from "./DetailCell";
|
||||||
|
import { toCreateResource } from "../routes/NewResource";
|
||||||
|
import { useRealm } from "../../context/realm-context/RealmContext";
|
||||||
|
import { toResourceDetails } from "../routes/Resource";
|
||||||
|
|
||||||
type ResourcesProps = {
|
type ResourcesProps = {
|
||||||
clientId: string;
|
clientId: string;
|
||||||
|
@ -37,6 +43,8 @@ export const AuthorizationResources = ({ clientId }: ResourcesProps) => {
|
||||||
const { t } = useTranslation("clients");
|
const { t } = useTranslation("clients");
|
||||||
const adminClient = useAdminClient();
|
const adminClient = useAdminClient();
|
||||||
const { addAlert, addError } = useAlerts();
|
const { addAlert, addError } = useAlerts();
|
||||||
|
const { realm } = useRealm();
|
||||||
|
|
||||||
const [resources, setResources] =
|
const [resources, setResources] =
|
||||||
useState<ExpandableResourceRepresentation[]>();
|
useState<ExpandableResourceRepresentation[]>();
|
||||||
const [selectedResource, setSelectedResource] =
|
const [selectedResource, setSelectedResource] =
|
||||||
|
@ -127,7 +135,7 @@ export const AuthorizationResources = ({ clientId }: ResourcesProps) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!resources) {
|
if (!resources) {
|
||||||
return <Spinner />;
|
return <KeycloakSpinner />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -143,6 +151,21 @@ export const AuthorizationResources = ({ clientId }: ResourcesProps) => {
|
||||||
setFirst(first);
|
setFirst(first);
|
||||||
setMax(max);
|
setMax(max);
|
||||||
}}
|
}}
|
||||||
|
toolbarItem={
|
||||||
|
<ToolbarItem>
|
||||||
|
<Button
|
||||||
|
data-testid="createResource"
|
||||||
|
component={(props) => (
|
||||||
|
<Link
|
||||||
|
{...props}
|
||||||
|
to={toCreateResource({ realm, id: clientId })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t("createResource")}
|
||||||
|
</Button>
|
||||||
|
</ToolbarItem>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<TableComposable aria-label={t("resources")} variant="compact">
|
<TableComposable aria-label={t("resources")} variant="compact">
|
||||||
<Thead>
|
<Thead>
|
||||||
|
@ -172,7 +195,17 @@ export const AuthorizationResources = ({ clientId }: ResourcesProps) => {
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Td>{resource.name}</Td>
|
<Td data-testid={`name-column-${resource.name}`}>
|
||||||
|
<Link
|
||||||
|
to={toResourceDetails({
|
||||||
|
realm,
|
||||||
|
id: clientId,
|
||||||
|
resourceId: resource._id!,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{resource.name}
|
||||||
|
</Link>
|
||||||
|
</Td>
|
||||||
<Td>{resource.type}</Td>
|
<Td>{resource.type}</Td>
|
||||||
<Td>{resource.owner?.name}</Td>
|
<Td>{resource.owner?.name}</Td>
|
||||||
<Td>
|
<Td>
|
||||||
|
|
106
src/clients/authorization/ScopePicker.tsx
Normal file
106
src/clients/authorization/ScopePicker.tsx
Normal file
|
@ -0,0 +1,106 @@
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Controller, useFormContext } from "react-hook-form";
|
||||||
|
import {
|
||||||
|
FormGroup,
|
||||||
|
Select,
|
||||||
|
SelectOption,
|
||||||
|
SelectVariant,
|
||||||
|
} from "@patternfly/react-core";
|
||||||
|
|
||||||
|
import { useAdminClient, useFetch } from "../../context/auth/AdminClient";
|
||||||
|
import { HelpItem } from "../../components/help-enabler/HelpItem";
|
||||||
|
|
||||||
|
type Scope = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ScopePicker = ({ clientId }: { clientId: string }) => {
|
||||||
|
const { t } = useTranslation("clients");
|
||||||
|
const { control } = useFormContext();
|
||||||
|
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [scopes, setScopes] = useState<JSX.Element[]>();
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
|
||||||
|
const adminClient = useAdminClient();
|
||||||
|
|
||||||
|
useFetch(
|
||||||
|
() => {
|
||||||
|
const params = {
|
||||||
|
id: clientId,
|
||||||
|
first: 0,
|
||||||
|
max: 20,
|
||||||
|
deep: false,
|
||||||
|
name: search,
|
||||||
|
};
|
||||||
|
return adminClient.clients.listAllScopes(params);
|
||||||
|
},
|
||||||
|
(scopes) =>
|
||||||
|
setScopes(
|
||||||
|
scopes.map((option) => (
|
||||||
|
<SelectOption key={option.id} value={option}>
|
||||||
|
{option.name}
|
||||||
|
</SelectOption>
|
||||||
|
))
|
||||||
|
),
|
||||||
|
[search]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormGroup
|
||||||
|
label={t("authorizationScopes")}
|
||||||
|
labelIcon={
|
||||||
|
<HelpItem
|
||||||
|
helpText="clients-help:scopes"
|
||||||
|
forLabel={t("scopes")}
|
||||||
|
forID={t(`common:helpLabel`, { label: t("scopes") })}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
fieldId="scopes"
|
||||||
|
>
|
||||||
|
<Controller
|
||||||
|
name="scopes"
|
||||||
|
defaultValue={[]}
|
||||||
|
control={control}
|
||||||
|
render={({ onChange, value }) => (
|
||||||
|
<Select
|
||||||
|
toggleId="scopes"
|
||||||
|
variant={SelectVariant.typeaheadMulti}
|
||||||
|
chipGroupProps={{
|
||||||
|
numChips: 3,
|
||||||
|
expandedText: t("common:hide"),
|
||||||
|
collapsedText: t("common:showRemaining"),
|
||||||
|
}}
|
||||||
|
onToggle={(open) => setOpen(open)}
|
||||||
|
isOpen={open}
|
||||||
|
selections={value.map((o: Scope) => o.name)}
|
||||||
|
onFilter={(_, value) => {
|
||||||
|
setSearch(value);
|
||||||
|
return scopes;
|
||||||
|
}}
|
||||||
|
onSelect={(_, selectedValue) => {
|
||||||
|
const option =
|
||||||
|
typeof selectedValue === "string"
|
||||||
|
? selectedValue
|
||||||
|
: (selectedValue as Scope).name;
|
||||||
|
const changedValue = value.find((o: Scope) => o.name === option)
|
||||||
|
? value.filter((item: Scope) => item.name !== option)
|
||||||
|
: [...value, selectedValue];
|
||||||
|
onChange(changedValue);
|
||||||
|
}}
|
||||||
|
onClear={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
setSearch("");
|
||||||
|
onChange([]);
|
||||||
|
}}
|
||||||
|
aria-label={t("authorizationScopes")}
|
||||||
|
>
|
||||||
|
{scopes}
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
);
|
||||||
|
};
|
|
@ -7,11 +7,11 @@ import {
|
||||||
FormGroup,
|
FormGroup,
|
||||||
PageSection,
|
PageSection,
|
||||||
Radio,
|
Radio,
|
||||||
Spinner,
|
|
||||||
Switch,
|
Switch,
|
||||||
} from "@patternfly/react-core";
|
} from "@patternfly/react-core";
|
||||||
|
|
||||||
import type ResourceServerRepresentation from "@keycloak/keycloak-admin-client/lib/defs/resourceServerRepresentation";
|
import type ResourceServerRepresentation from "@keycloak/keycloak-admin-client/lib/defs/resourceServerRepresentation";
|
||||||
|
import { KeycloakSpinner } from "../../components/keycloak-spinner/KeycloakSpinner";
|
||||||
import { useAdminClient, useFetch } from "../../context/auth/AdminClient";
|
import { useAdminClient, useFetch } from "../../context/auth/AdminClient";
|
||||||
import { FormAccess } from "../../components/form-access/FormAccess";
|
import { FormAccess } from "../../components/form-access/FormAccess";
|
||||||
import { HelpItem } from "../../components/help-enabler/HelpItem";
|
import { HelpItem } from "../../components/help-enabler/HelpItem";
|
||||||
|
@ -43,7 +43,7 @@ export const AuthorizationSettings = ({ clientId }: { clientId: string }) => {
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!resource) {
|
if (!resource) {
|
||||||
return <Spinner />;
|
return <KeycloakSpinner />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
4
src/clients/authorization/resource-details.css
Normal file
4
src/clients/authorization/resource-details.css
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
|
||||||
|
.keycloak__resource-details__form {
|
||||||
|
--pf-c-form--m-horizontal__group-label--md--GridColumnWidth: 10rem;
|
||||||
|
}
|
|
@ -162,5 +162,16 @@ export default {
|
||||||
"The decision strategy dictates how permissions are evaluated and how a final decision is obtained. 'Affirmative' means that at least one permission must evaluate to a positive decision in order to grant access to a resource and its scopes. 'Unanimous' means that all permissions must evaluate to a positive decision in order for the final decision to be also positive.",
|
"The decision strategy dictates how permissions are evaluated and how a final decision is obtained. 'Affirmative' means that at least one permission must evaluate to a positive decision in order to grant access to a resource and its scopes. 'Unanimous' means that all permissions must evaluate to a positive decision in order for the final decision to be also positive.",
|
||||||
allowRemoteResourceManagement:
|
allowRemoteResourceManagement:
|
||||||
"Should resources be managed remotely by the resource server? If false, resources can be managed only from this admin console.",
|
"Should resources be managed remotely by the resource server? If false, resources can be managed only from this admin console.",
|
||||||
|
resourceName:
|
||||||
|
"A unique name for this resource. The name can be used to uniquely identify a resource, useful when querying for a specific resource.",
|
||||||
|
displayName:
|
||||||
|
"A unique name for this resource. The name can be used to uniquely identify a resource, useful when querying for a specific resource.",
|
||||||
|
type: "The type of this resource. It can be used to group different resource instances with the same type.",
|
||||||
|
uris: "Set of URIs which are protected by resource.",
|
||||||
|
scopes: "The scopes associated with this resource.",
|
||||||
|
iconUri: "A URI pointing to an icon.",
|
||||||
|
ownerManagedAccess:
|
||||||
|
"If enabled, the access to this resource can be managed by the resource owner.",
|
||||||
|
resourceAttribute: "The attributes associated wth the resource.",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
@ -61,6 +61,20 @@ export default {
|
||||||
UNANIMOUS: "Unanimous",
|
UNANIMOUS: "Unanimous",
|
||||||
AFFIRMATIVE: "Affirmative",
|
AFFIRMATIVE: "Affirmative",
|
||||||
},
|
},
|
||||||
|
createResource: "Create resource",
|
||||||
|
createResourceBasedPermission: "Create resource-based permission",
|
||||||
|
displayName: "Display name",
|
||||||
|
type: "Type",
|
||||||
|
addUri: "Add URI",
|
||||||
|
authorizationScopes: "Authorization scopes",
|
||||||
|
iconUri: "Icon URI",
|
||||||
|
ownerManagedAccess: "User-Managed access enabled",
|
||||||
|
resourceAttribute: "Resource attribute",
|
||||||
|
createResourceSuccess: "Resource created successfully",
|
||||||
|
updateResourceSuccess: "Resource successfully updated",
|
||||||
|
resourceSaveError: "Could not persist resource due to {{error}}",
|
||||||
|
associatedPermissions: "Associated permission",
|
||||||
|
allowRemoteResourceManagement: "Remote resource management",
|
||||||
resources: "Resources",
|
resources: "Resources",
|
||||||
owner: "Owner",
|
owner: "Owner",
|
||||||
uris: "URIs",
|
uris: "URIs",
|
||||||
|
@ -73,8 +87,6 @@ export default {
|
||||||
"The permissions below will be removed when they are no longer used by other resources:",
|
"The permissions below will be removed when they are no longer used by other resources:",
|
||||||
resourceDeletedSuccess: "The resource successfully deleted",
|
resourceDeletedSuccess: "The resource successfully deleted",
|
||||||
resourceDeletedError: "Could not remove the resource {{error}}",
|
resourceDeletedError: "Could not remove the resource {{error}}",
|
||||||
associatedPermissions: "Associated permission",
|
|
||||||
allowRemoteResourceManagement: "Remote resource management",
|
|
||||||
assignedClientScope: "Assigned client scope",
|
assignedClientScope: "Assigned client scope",
|
||||||
assignedType: "Assigned type",
|
assignedType: "Assigned type",
|
||||||
hideInheritedRoles: "Hide inherited roles",
|
hideInheritedRoles: "Hide inherited roles",
|
||||||
|
|
|
@ -5,6 +5,8 @@ import { ClientsRoute } from "./routes/Clients";
|
||||||
import { CreateInitialAccessTokenRoute } from "./routes/CreateInitialAccessToken";
|
import { CreateInitialAccessTokenRoute } from "./routes/CreateInitialAccessToken";
|
||||||
import { ImportClientRoute } from "./routes/ImportClient";
|
import { ImportClientRoute } from "./routes/ImportClient";
|
||||||
import { MapperRoute } from "./routes/Mapper";
|
import { MapperRoute } from "./routes/Mapper";
|
||||||
|
import { NewResourceRoute } from "./routes/NewResource";
|
||||||
|
import { ResourceDetailsRoute } from "./routes/Resource";
|
||||||
|
|
||||||
const routes: RouteDef[] = [
|
const routes: RouteDef[] = [
|
||||||
AddClientRoute,
|
AddClientRoute,
|
||||||
|
@ -13,6 +15,8 @@ const routes: RouteDef[] = [
|
||||||
CreateInitialAccessTokenRoute,
|
CreateInitialAccessTokenRoute,
|
||||||
ClientRoute,
|
ClientRoute,
|
||||||
MapperRoute,
|
MapperRoute,
|
||||||
|
NewResourceRoute,
|
||||||
|
ResourceDetailsRoute,
|
||||||
];
|
];
|
||||||
|
|
||||||
export default routes;
|
export default routes;
|
||||||
|
|
|
@ -8,7 +8,8 @@ export type ClientTab =
|
||||||
| "roles"
|
| "roles"
|
||||||
| "clientScopes"
|
| "clientScopes"
|
||||||
| "advanced"
|
| "advanced"
|
||||||
| "mappers";
|
| "mappers"
|
||||||
|
| "authorization";
|
||||||
|
|
||||||
export type ClientParams = {
|
export type ClientParams = {
|
||||||
realm: string;
|
realm: string;
|
||||||
|
|
19
src/clients/routes/NewResource.ts
Normal file
19
src/clients/routes/NewResource.ts
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
import type { LocationDescriptorObject } from "history";
|
||||||
|
import type { RouteDef } from "../../route-config";
|
||||||
|
import { generatePath } from "react-router-dom";
|
||||||
|
import { lazy } from "react";
|
||||||
|
|
||||||
|
export type NewResourceParams = { realm: string; id: string };
|
||||||
|
|
||||||
|
export const NewResourceRoute: RouteDef = {
|
||||||
|
path: "/:realm/clients/:id/authorization/new",
|
||||||
|
component: lazy(() => import("../authorization/ResourceDetails")),
|
||||||
|
breadcrumb: (t) => t("clients:createResource"),
|
||||||
|
access: "manage-clients",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const toCreateResource = (
|
||||||
|
params: NewResourceParams
|
||||||
|
): LocationDescriptorObject => ({
|
||||||
|
pathname: generatePath(NewResourceRoute.path, params),
|
||||||
|
});
|
23
src/clients/routes/Resource.ts
Normal file
23
src/clients/routes/Resource.ts
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
import type { LocationDescriptorObject } from "history";
|
||||||
|
import type { RouteDef } from "../../route-config";
|
||||||
|
import { generatePath } from "react-router-dom";
|
||||||
|
import { lazy } from "react";
|
||||||
|
|
||||||
|
export type ResourceDetailsParams = {
|
||||||
|
realm: string;
|
||||||
|
id: string;
|
||||||
|
resourceId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ResourceDetailsRoute: RouteDef = {
|
||||||
|
path: "/:realm/clients/:id/authorization/:resourceId?",
|
||||||
|
component: lazy(() => import("../authorization/ResourceDetails")),
|
||||||
|
breadcrumb: (t) => t("clients:createResource"),
|
||||||
|
access: "manage-clients",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const toResourceDetails = (
|
||||||
|
params: ResourceDetailsParams
|
||||||
|
): LocationDescriptorObject => ({
|
||||||
|
pathname: generatePath(ResourceDetailsRoute.path, params),
|
||||||
|
});
|
|
@ -15,6 +15,8 @@ export default {
|
||||||
delete: "Delete",
|
delete: "Delete",
|
||||||
remove: "Remove",
|
remove: "Remove",
|
||||||
search: "Search",
|
search: "Search",
|
||||||
|
key: "Key",
|
||||||
|
value: "Value",
|
||||||
noSearchResults: "No search results",
|
noSearchResults: "No search results",
|
||||||
noSearchResultsInstructions: "Click on the search bar above to search",
|
noSearchResultsInstructions: "Click on the search bar above to search",
|
||||||
next: "Next",
|
next: "Next",
|
||||||
|
|
|
@ -1,24 +1,13 @@
|
||||||
import React, { useEffect } from "react";
|
import React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { ArrayField, UseFormMethods } from "react-hook-form";
|
import { FormProvider, UseFormMethods } from "react-hook-form";
|
||||||
import { ActionGroup, Button, TextInput } from "@patternfly/react-core";
|
import { ActionGroup, Button } from "@patternfly/react-core";
|
||||||
import {
|
|
||||||
TableComposable,
|
|
||||||
Tbody,
|
|
||||||
Td,
|
|
||||||
Th,
|
|
||||||
Thead,
|
|
||||||
Tr,
|
|
||||||
} from "@patternfly/react-table";
|
|
||||||
import { MinusCircleIcon, PlusCircleIcon } from "@patternfly/react-icons";
|
|
||||||
|
|
||||||
import type { RoleRepresentation } from "../../model/role-model";
|
import type { RoleRepresentation } from "../../model/role-model";
|
||||||
|
import type { KeyValueType } from "./attribute-convert";
|
||||||
|
import { AttributeInput } from "../attribute-input/AttributeInput";
|
||||||
import { FormAccess } from "../form-access/FormAccess";
|
import { FormAccess } from "../form-access/FormAccess";
|
||||||
|
|
||||||
import "./attribute-form.css";
|
|
||||||
|
|
||||||
export type KeyValueType = { key: string; value: string };
|
|
||||||
|
|
||||||
export type AttributeForm = Omit<RoleRepresentation, "attributes"> & {
|
export type AttributeForm = Omit<RoleRepresentation, "attributes"> & {
|
||||||
attributes?: KeyValueType[];
|
attributes?: KeyValueType[];
|
||||||
};
|
};
|
||||||
|
@ -27,197 +16,35 @@ export type AttributesFormProps = {
|
||||||
form: UseFormMethods<AttributeForm>;
|
form: UseFormMethods<AttributeForm>;
|
||||||
save?: (model: AttributeForm) => void;
|
save?: (model: AttributeForm) => void;
|
||||||
reset?: () => void;
|
reset?: () => void;
|
||||||
array: {
|
|
||||||
fields: Partial<ArrayField<Record<string, any>, "id">>[];
|
|
||||||
append: (
|
|
||||||
value: Partial<Record<string, any>> | Partial<Record<string, any>>[],
|
|
||||||
shouldFocus?: boolean | undefined
|
|
||||||
) => void;
|
|
||||||
remove: (index?: number | number[] | undefined) => void;
|
|
||||||
};
|
|
||||||
inConfig?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const arrayToAttributes = (attributeArray: KeyValueType[]) => {
|
export const AttributesForm = ({ form, reset, save }: AttributesFormProps) => {
|
||||||
const initValue: { [index: string]: string[] } = {};
|
|
||||||
return attributeArray.reduce((acc, attribute) => {
|
|
||||||
acc[attribute.key] = !acc[attribute.key]
|
|
||||||
? [attribute.value]
|
|
||||||
: acc[attribute.key].concat(attribute.value);
|
|
||||||
return acc;
|
|
||||||
}, initValue);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const attributesToArray = (attributes?: {
|
|
||||||
[key: string]: string[];
|
|
||||||
}): KeyValueType[] => {
|
|
||||||
if (!attributes || Object.keys(attributes).length == 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
const initValue: KeyValueType[] = [];
|
|
||||||
return Object.keys(attributes).reduce((acc, key) => {
|
|
||||||
return acc.concat(
|
|
||||||
attributes[key].map((value) => {
|
|
||||||
return {
|
|
||||||
key: key,
|
|
||||||
value: value,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}, initValue);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const AttributesForm = ({
|
|
||||||
form: { handleSubmit, register, formState, errors, watch },
|
|
||||||
array: { fields, append, remove },
|
|
||||||
reset,
|
|
||||||
save,
|
|
||||||
inConfig,
|
|
||||||
}: AttributesFormProps) => {
|
|
||||||
const { t } = useTranslation("roles");
|
const { t } = useTranslation("roles");
|
||||||
|
|
||||||
const columns = ["Key", "Value"];
|
|
||||||
|
|
||||||
const noSaveCancelButtons = !save && !reset;
|
const noSaveCancelButtons = !save && !reset;
|
||||||
|
const {
|
||||||
const watchLast = inConfig
|
formState: { isDirty },
|
||||||
? watch(`config.attributes[${fields.length - 1}].key`, "")
|
handleSubmit,
|
||||||
: watch(`attributes[${fields.length - 1}].key`, "");
|
} = form;
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (fields.length === 0) {
|
|
||||||
append({ key: "", value: "" });
|
|
||||||
}
|
|
||||||
}, [fields]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormAccess
|
<FormAccess
|
||||||
role="manage-realm"
|
role="manage-realm"
|
||||||
onSubmit={save ? handleSubmit(save) : undefined}
|
onSubmit={save ? handleSubmit(save) : undefined}
|
||||||
>
|
>
|
||||||
<TableComposable
|
<FormProvider {...form}>
|
||||||
className="kc-attributes__table"
|
<AttributeInput name="attributes" />
|
||||||
aria-label="Role attribute keys and values"
|
</FormProvider>
|
||||||
variant="compact"
|
|
||||||
borders={false}
|
|
||||||
>
|
|
||||||
<Thead>
|
|
||||||
<Tr>
|
|
||||||
<Th id="key" width={40}>
|
|
||||||
{columns[0]}
|
|
||||||
</Th>
|
|
||||||
<Th id="value" width={40}>
|
|
||||||
{columns[1]}
|
|
||||||
</Th>
|
|
||||||
</Tr>
|
|
||||||
</Thead>
|
|
||||||
<Tbody>
|
|
||||||
{fields.map((attribute, rowIndex) => (
|
|
||||||
<Tr key={attribute.id} data-testid="attribute-row">
|
|
||||||
<Td
|
|
||||||
key={`${attribute.id}-key`}
|
|
||||||
id={`text-input-${rowIndex}-key`}
|
|
||||||
dataLabel={columns[0]}
|
|
||||||
>
|
|
||||||
<TextInput
|
|
||||||
name={
|
|
||||||
inConfig
|
|
||||||
? `config.attributes[${rowIndex}].key`
|
|
||||||
: `attributes[${rowIndex}].key`
|
|
||||||
}
|
|
||||||
ref={register()}
|
|
||||||
aria-label="key-input"
|
|
||||||
defaultValue={attribute.key}
|
|
||||||
validated={
|
|
||||||
errors.attributes?.[rowIndex] ? "error" : "default"
|
|
||||||
}
|
|
||||||
data-testid="attribute-key-input"
|
|
||||||
/>
|
|
||||||
</Td>
|
|
||||||
<Td
|
|
||||||
key={`${attribute}-value`}
|
|
||||||
id={`text-input-${rowIndex}-value`}
|
|
||||||
dataLabel={columns[1]}
|
|
||||||
>
|
|
||||||
<TextInput
|
|
||||||
name={
|
|
||||||
inConfig
|
|
||||||
? `config.attributes[${rowIndex}].value`
|
|
||||||
: `attributes[${rowIndex}].value`
|
|
||||||
}
|
|
||||||
ref={register()}
|
|
||||||
aria-label="value-input"
|
|
||||||
defaultValue={attribute.value}
|
|
||||||
data-testid="attribute-value-input"
|
|
||||||
/>
|
|
||||||
</Td>
|
|
||||||
{rowIndex !== fields.length - 1 && fields.length - 1 !== 0 && (
|
|
||||||
<Td
|
|
||||||
key="minus-button"
|
|
||||||
id={`kc-minus-button-${rowIndex}`}
|
|
||||||
dataLabel={columns[2]}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
id={`minus-button-${rowIndex}`}
|
|
||||||
aria-label={`remove ${attribute.key} with value ${attribute.value} `}
|
|
||||||
variant="link"
|
|
||||||
className="kc-attributes__minus-icon"
|
|
||||||
onClick={() => remove(rowIndex)}
|
|
||||||
>
|
|
||||||
<MinusCircleIcon />
|
|
||||||
</Button>
|
|
||||||
</Td>
|
|
||||||
)}
|
|
||||||
{rowIndex === fields.length - 1 && (
|
|
||||||
<Td key="add-button" id="add-button" dataLabel={columns[2]}>
|
|
||||||
{fields.length !== 1 && (
|
|
||||||
<Button
|
|
||||||
id={`minus-button-${rowIndex}`}
|
|
||||||
aria-label={`remove ${attribute.key} with value ${attribute.value} `}
|
|
||||||
variant="link"
|
|
||||||
className="kc-attributes__minus-icon"
|
|
||||||
onClick={() => remove(rowIndex)}
|
|
||||||
>
|
|
||||||
<MinusCircleIcon />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Td>
|
|
||||||
)}
|
|
||||||
</Tr>
|
|
||||||
))}
|
|
||||||
<Tr>
|
|
||||||
<Td>
|
|
||||||
<Button
|
|
||||||
aria-label={t("roles:addAttributeText")}
|
|
||||||
id="plus-icon"
|
|
||||||
variant="link"
|
|
||||||
className="kc-attributes__plus-icon"
|
|
||||||
onClick={() => append({ key: "", value: "" })}
|
|
||||||
icon={<PlusCircleIcon />}
|
|
||||||
isDisabled={!watchLast}
|
|
||||||
data-testid="attribute-add-row"
|
|
||||||
>
|
|
||||||
{t("roles:addAttributeText")}
|
|
||||||
</Button>
|
|
||||||
</Td>
|
|
||||||
</Tr>
|
|
||||||
</Tbody>
|
|
||||||
</TableComposable>
|
|
||||||
{!noSaveCancelButtons && (
|
{!noSaveCancelButtons && (
|
||||||
<ActionGroup className="kc-attributes__action-group">
|
<ActionGroup className="kc-attributes__action-group">
|
||||||
<Button
|
<Button
|
||||||
data-testid="save-attributes"
|
data-testid="save-attributes"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
type="submit"
|
type="submit"
|
||||||
isDisabled={!watchLast}
|
isDisabled={!isDirty}
|
||||||
>
|
>
|
||||||
{t("common:save")}
|
{t("common:save")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button onClick={reset} variant="link" isDisabled={!isDirty}>
|
||||||
onClick={reset}
|
|
||||||
variant="link"
|
|
||||||
isDisabled={!formState.isDirty}
|
|
||||||
>
|
|
||||||
{t("common:revert")}
|
{t("common:revert")}
|
||||||
</Button>
|
</Button>
|
||||||
</ActionGroup>
|
</ActionGroup>
|
||||||
|
|
68
src/components/attribute-form/attribute-convert.test.ts
Normal file
68
src/components/attribute-form/attribute-convert.test.ts
Normal file
|
@ -0,0 +1,68 @@
|
||||||
|
import {
|
||||||
|
arrayToAttributes,
|
||||||
|
attributesToArray,
|
||||||
|
KeyValueType,
|
||||||
|
} from "./attribute-convert";
|
||||||
|
|
||||||
|
jest.mock("react");
|
||||||
|
|
||||||
|
describe("Tests the convert functions for attribute input", () => {
|
||||||
|
it("converts empty array into form value", () => {
|
||||||
|
const given: KeyValueType[] = [];
|
||||||
|
|
||||||
|
//when
|
||||||
|
const result = arrayToAttributes(given);
|
||||||
|
|
||||||
|
//then
|
||||||
|
expect(result).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("converts array into form value", () => {
|
||||||
|
const given = [{ key: "theKey", value: "theValue" }];
|
||||||
|
|
||||||
|
//when
|
||||||
|
const result = arrayToAttributes(given);
|
||||||
|
|
||||||
|
//then
|
||||||
|
expect(result).toEqual({ theKey: ["theValue"] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("convert only values", () => {
|
||||||
|
const given = [
|
||||||
|
{ key: "theKey", value: "theValue" },
|
||||||
|
{ key: "", value: "" },
|
||||||
|
];
|
||||||
|
|
||||||
|
//when
|
||||||
|
const result = arrayToAttributes(given);
|
||||||
|
|
||||||
|
//then
|
||||||
|
expect(result).toEqual({ theKey: ["theValue"] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("convert empty object to attributes", () => {
|
||||||
|
const given: {
|
||||||
|
[key: string]: string[];
|
||||||
|
} = {};
|
||||||
|
|
||||||
|
//when
|
||||||
|
const result = attributesToArray(given);
|
||||||
|
|
||||||
|
//then
|
||||||
|
expect(result).toEqual([{ key: "", value: "" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("convert object to attributes", () => {
|
||||||
|
const given = { one: ["1"], two: ["2"] };
|
||||||
|
|
||||||
|
//when
|
||||||
|
const result = attributesToArray(given);
|
||||||
|
|
||||||
|
//then
|
||||||
|
expect(result).toEqual([
|
||||||
|
{ key: "one", value: "1" },
|
||||||
|
{ key: "two", value: "2" },
|
||||||
|
{ key: "", value: "" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
20
src/components/attribute-form/attribute-convert.ts
Normal file
20
src/components/attribute-form/attribute-convert.ts
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
export type KeyValueType = { key: string; value: string };
|
||||||
|
|
||||||
|
export const arrayToAttributes = (
|
||||||
|
attributeArray: KeyValueType[] = []
|
||||||
|
): Record<string, string[]> =>
|
||||||
|
Object.fromEntries(
|
||||||
|
attributeArray
|
||||||
|
.filter(({ key }) => key !== "")
|
||||||
|
.map(({ key, value }) => [key, [value]])
|
||||||
|
);
|
||||||
|
|
||||||
|
export const attributesToArray = (
|
||||||
|
attributes: Record<string, string[]> = {}
|
||||||
|
): KeyValueType[] => {
|
||||||
|
const result = Object.entries(attributes).flatMap(([key, value]) =>
|
||||||
|
value.map<KeyValueType>((value) => ({ key, value }))
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.concat({ key: "", value: "" });
|
||||||
|
};
|
106
src/components/attribute-input/AttributeInput.tsx
Normal file
106
src/components/attribute-input/AttributeInput.tsx
Normal file
|
@ -0,0 +1,106 @@
|
||||||
|
import React, { useEffect } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useFieldArray, useFormContext } from "react-hook-form";
|
||||||
|
import { Button, TextInput } from "@patternfly/react-core";
|
||||||
|
import {
|
||||||
|
TableComposable,
|
||||||
|
Tbody,
|
||||||
|
Td,
|
||||||
|
Th,
|
||||||
|
Thead,
|
||||||
|
Tr,
|
||||||
|
} from "@patternfly/react-table";
|
||||||
|
import { MinusCircleIcon, PlusCircleIcon } from "@patternfly/react-icons";
|
||||||
|
|
||||||
|
import "../attribute-form/attribute-form.css";
|
||||||
|
|
||||||
|
type AttributeInputProps = {
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AttributeInput = ({ name }: AttributeInputProps) => {
|
||||||
|
const { t } = useTranslation("common");
|
||||||
|
const { control, register, watch } = useFormContext();
|
||||||
|
const { fields, append, remove } = useFieldArray({
|
||||||
|
control: control,
|
||||||
|
name,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!fields.length) {
|
||||||
|
append({ key: "", value: "" });
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const watchLast = watch(`${name}[${fields.length - 1}].key`, "");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableComposable
|
||||||
|
className="kc-attributes__table"
|
||||||
|
aria-label="Role attribute keys and values"
|
||||||
|
variant="compact"
|
||||||
|
borders={false}
|
||||||
|
>
|
||||||
|
<Thead>
|
||||||
|
<Tr>
|
||||||
|
<Th id="key" width={40}>
|
||||||
|
{t("key")}
|
||||||
|
</Th>
|
||||||
|
<Th id="value" width={40}>
|
||||||
|
{t("value")}
|
||||||
|
</Th>
|
||||||
|
</Tr>
|
||||||
|
</Thead>
|
||||||
|
<Tbody>
|
||||||
|
{fields.map((attribute, rowIndex) => (
|
||||||
|
<Tr key={attribute.id} data-testid="attribute-row">
|
||||||
|
<Td>
|
||||||
|
<TextInput
|
||||||
|
id={`${attribute.id}-key`}
|
||||||
|
name={`${name}[${rowIndex}].key`}
|
||||||
|
ref={register()}
|
||||||
|
defaultValue={attribute.key}
|
||||||
|
data-testid="attribute-key-input"
|
||||||
|
/>
|
||||||
|
</Td>
|
||||||
|
<Td>
|
||||||
|
<TextInput
|
||||||
|
id={`${attribute.id}-value`}
|
||||||
|
name={`${name}[${rowIndex}].value`}
|
||||||
|
ref={register()}
|
||||||
|
defaultValue={attribute.value}
|
||||||
|
data-testid="attribute-value-input"
|
||||||
|
/>
|
||||||
|
</Td>
|
||||||
|
<Td key="minus-button" id={`kc-minus-button-${rowIndex}`}>
|
||||||
|
<Button
|
||||||
|
id={`minus-button-${rowIndex}`}
|
||||||
|
variant="link"
|
||||||
|
className="kc-attributes__minus-icon"
|
||||||
|
onClick={() => remove(rowIndex)}
|
||||||
|
>
|
||||||
|
<MinusCircleIcon />
|
||||||
|
</Button>
|
||||||
|
</Td>
|
||||||
|
</Tr>
|
||||||
|
))}
|
||||||
|
<Tr>
|
||||||
|
<Td>
|
||||||
|
<Button
|
||||||
|
aria-label={t("roles:addAttributeText")}
|
||||||
|
id="plus-icon"
|
||||||
|
variant="link"
|
||||||
|
className="kc-attributes__plus-icon"
|
||||||
|
onClick={() => append({ key: "", value: "" })}
|
||||||
|
icon={<PlusCircleIcon />}
|
||||||
|
isDisabled={!watchLast}
|
||||||
|
data-testid="attribute-add-row"
|
||||||
|
>
|
||||||
|
{t("roles:addAttributeText")}
|
||||||
|
</Button>
|
||||||
|
</Td>
|
||||||
|
</Tr>
|
||||||
|
</Tbody>
|
||||||
|
</TableComposable>
|
||||||
|
);
|
||||||
|
};
|
|
@ -1,6 +1,6 @@
|
||||||
import React, { useEffect } from "react";
|
import React, { useEffect } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useFieldArray, useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import {
|
import {
|
||||||
AlertVariant,
|
AlertVariant,
|
||||||
PageSection,
|
PageSection,
|
||||||
|
@ -9,11 +9,13 @@ import {
|
||||||
|
|
||||||
import { useAlerts } from "../components/alert/Alerts";
|
import { useAlerts } from "../components/alert/Alerts";
|
||||||
import {
|
import {
|
||||||
arrayToAttributes,
|
|
||||||
AttributeForm,
|
AttributeForm,
|
||||||
AttributesForm,
|
AttributesForm,
|
||||||
attributesToArray,
|
|
||||||
} from "../components/attribute-form/AttributeForm";
|
} from "../components/attribute-form/AttributeForm";
|
||||||
|
import {
|
||||||
|
arrayToAttributes,
|
||||||
|
attributesToArray,
|
||||||
|
} from "../components/attribute-form/attribute-convert";
|
||||||
import { useAdminClient } from "../context/auth/AdminClient";
|
import { useAdminClient } from "../context/auth/AdminClient";
|
||||||
|
|
||||||
import { getLastId } from "./groupIdUtils";
|
import { getLastId } from "./groupIdUtils";
|
||||||
|
@ -24,10 +26,9 @@ export const GroupAttributes = () => {
|
||||||
const { t } = useTranslation("groups");
|
const { t } = useTranslation("groups");
|
||||||
const adminClient = useAdminClient();
|
const adminClient = useAdminClient();
|
||||||
const { addAlert, addError } = useAlerts();
|
const { addAlert, addError } = useAlerts();
|
||||||
const form = useForm<AttributeForm>({ mode: "onChange" });
|
const form = useForm<AttributeForm>({
|
||||||
const { fields, append, remove } = useFieldArray({
|
mode: "onChange",
|
||||||
control: form.control,
|
shouldUnregister: false,
|
||||||
name: "attributes",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
@ -35,9 +36,7 @@ export const GroupAttributes = () => {
|
||||||
const { currentGroup, subGroups, setSubGroups } = useSubGroups();
|
const { currentGroup, subGroups, setSubGroups } = useSubGroups();
|
||||||
|
|
||||||
const convertAttributes = (attr?: Record<string, any>) => {
|
const convertAttributes = (attr?: Record<string, any>) => {
|
||||||
const attributes = attributesToArray(attr || currentGroup().attributes!);
|
return attributesToArray(attr || currentGroup().attributes!);
|
||||||
attributes.push({ key: "", value: "" });
|
|
||||||
return attributes;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -54,7 +53,6 @@ export const GroupAttributes = () => {
|
||||||
...subGroups.slice(0, subGroups.length - 1),
|
...subGroups.slice(0, subGroups.length - 1),
|
||||||
{ ...group, attributes },
|
{ ...group, attributes },
|
||||||
]);
|
]);
|
||||||
form.setValue("attributes", convertAttributes(attributes));
|
|
||||||
addAlert(t("groupUpdated"), AlertVariant.success);
|
addAlert(t("groupUpdated"), AlertVariant.success);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
addError("groups:groupUpdateError", error);
|
addError("groups:groupUpdateError", error);
|
||||||
|
@ -66,7 +64,6 @@ export const GroupAttributes = () => {
|
||||||
<AttributesForm
|
<AttributesForm
|
||||||
form={form}
|
form={form}
|
||||||
save={save}
|
save={save}
|
||||||
array={{ fields, append, remove }}
|
|
||||||
reset={() =>
|
reset={() =>
|
||||||
form.reset({
|
form.reset({
|
||||||
attributes: convertAttributes(),
|
attributes: convertAttributes(),
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import React, { useMemo, useState } from "react";
|
import React, { useMemo, useState } from "react";
|
||||||
import { Link, useHistory, useParams } from "react-router-dom";
|
import { Link, useHistory, useParams } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Controller, useFieldArray, useForm } from "react-hook-form";
|
import { Controller, FormProvider, useForm } from "react-hook-form";
|
||||||
import {
|
import {
|
||||||
ActionGroup,
|
ActionGroup,
|
||||||
AlertVariant,
|
AlertVariant,
|
||||||
|
@ -20,10 +20,8 @@ import { useRealm } from "../../context/realm-context/RealmContext";
|
||||||
|
|
||||||
import { HelpItem } from "../../components/help-enabler/HelpItem";
|
import { HelpItem } from "../../components/help-enabler/HelpItem";
|
||||||
import { ViewHeader } from "../../components/view-header/ViewHeader";
|
import { ViewHeader } from "../../components/view-header/ViewHeader";
|
||||||
import {
|
import { AttributeInput } from "../../components/attribute-input/AttributeInput";
|
||||||
AttributeForm,
|
import type { AttributeForm } from "../../components/attribute-form/AttributeForm";
|
||||||
AttributesForm,
|
|
||||||
} from "../../components/attribute-form/AttributeForm";
|
|
||||||
import { FormAccess } from "../../components/form-access/FormAccess";
|
import { FormAccess } from "../../components/form-access/FormAccess";
|
||||||
import { useAdminClient, useFetch } from "../../context/auth/AdminClient";
|
import { useAdminClient, useFetch } from "../../context/auth/AdminClient";
|
||||||
import type { IdentityProviderAddMapperParams } from "../routes/AddMapper";
|
import type { IdentityProviderAddMapperParams } from "../routes/AddMapper";
|
||||||
|
@ -144,11 +142,6 @@ export default function AddMapper() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const { append, remove, fields } = useFieldArray({
|
|
||||||
control: form.control,
|
|
||||||
name: "config.attributes",
|
|
||||||
});
|
|
||||||
|
|
||||||
useFetch(
|
useFetch(
|
||||||
() =>
|
() =>
|
||||||
Promise.all([
|
Promise.all([
|
||||||
|
@ -177,10 +170,6 @@ export default function AddMapper() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mapper.config?.attribute) {
|
|
||||||
form.setValue("config.attributes", value.attribute);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mapper.config?.attributes) {
|
if (mapper.config?.attributes) {
|
||||||
form.setValue("config.attributes", JSON.parse(value.attributes));
|
form.setValue("config.attributes", JSON.parse(value.attributes));
|
||||||
}
|
}
|
||||||
|
@ -344,11 +333,9 @@ export default function AddMapper() {
|
||||||
}
|
}
|
||||||
fieldId="kc-gui-order"
|
fieldId="kc-gui-order"
|
||||||
>
|
>
|
||||||
<AttributesForm
|
<FormProvider {...form}>
|
||||||
form={form}
|
<AttributeInput name="config.attributes" />
|
||||||
inConfig
|
</FormProvider>
|
||||||
array={{ fields, append, remove }}
|
|
||||||
/>
|
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
<FormGroup
|
<FormGroup
|
||||||
label={
|
label={
|
||||||
|
|
|
@ -9,7 +9,7 @@ import {
|
||||||
TabTitleText,
|
TabTitleText,
|
||||||
} from "@patternfly/react-core";
|
} from "@patternfly/react-core";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useFieldArray, useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { omit } from "lodash";
|
import { omit } from "lodash";
|
||||||
|
|
||||||
import { useAlerts } from "../components/alert/Alerts";
|
import { useAlerts } from "../components/alert/Alerts";
|
||||||
|
@ -17,10 +17,12 @@ import { useAdminClient, useFetch } from "../context/auth/AdminClient";
|
||||||
import type RoleRepresentation from "@keycloak/keycloak-admin-client/lib/defs/roleRepresentation";
|
import type RoleRepresentation from "@keycloak/keycloak-admin-client/lib/defs/roleRepresentation";
|
||||||
import {
|
import {
|
||||||
AttributesForm,
|
AttributesForm,
|
||||||
attributesToArray,
|
|
||||||
arrayToAttributes,
|
|
||||||
AttributeForm,
|
AttributeForm,
|
||||||
} from "../components/attribute-form/AttributeForm";
|
} from "../components/attribute-form/AttributeForm";
|
||||||
|
import {
|
||||||
|
attributesToArray,
|
||||||
|
arrayToAttributes,
|
||||||
|
} from "../components/attribute-form/attribute-convert";
|
||||||
import { KeycloakSpinner } from "../components/keycloak-spinner/KeycloakSpinner";
|
import { KeycloakSpinner } from "../components/keycloak-spinner/KeycloakSpinner";
|
||||||
import { ViewHeader } from "../components/view-header/ViewHeader";
|
import { ViewHeader } from "../components/view-header/ViewHeader";
|
||||||
import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog";
|
import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog";
|
||||||
|
@ -43,7 +45,7 @@ export default function RealmRoleTabs() {
|
||||||
const form = useForm<AttributeForm>({
|
const form = useForm<AttributeForm>({
|
||||||
mode: "onChange",
|
mode: "onChange",
|
||||||
});
|
});
|
||||||
const { control, setValue, getValues, trigger, reset } = form;
|
const { setValue, getValues, trigger, reset } = form;
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
|
|
||||||
const adminClient = useAdminClient();
|
const adminClient = useAdminClient();
|
||||||
|
@ -101,11 +103,6 @@ export default function RealmRoleTabs() {
|
||||||
[key]
|
[key]
|
||||||
);
|
);
|
||||||
|
|
||||||
const { fields, append, remove } = useFieldArray({
|
|
||||||
control,
|
|
||||||
name: "attributes",
|
|
||||||
});
|
|
||||||
|
|
||||||
const save = async () => {
|
const save = async () => {
|
||||||
try {
|
try {
|
||||||
const values = getValues();
|
const values = getValues();
|
||||||
|
@ -379,7 +376,6 @@ export default function RealmRoleTabs() {
|
||||||
<AttributesForm
|
<AttributesForm
|
||||||
form={form}
|
form={form}
|
||||||
save={save}
|
save={save}
|
||||||
array={{ fields, append, remove }}
|
|
||||||
reset={() => reset(role)}
|
reset={() => reset(role)}
|
||||||
/>
|
/>
|
||||||
</Tab>
|
</Tab>
|
||||||
|
|
|
@ -346,8 +346,6 @@ export default {
|
||||||
"Unable to save user profile, the provided information is not valid JSON.",
|
"Unable to save user profile, the provided information is not valid JSON.",
|
||||||
userProfileSuccess: "User profile settings successfully updated.",
|
userProfileSuccess: "User profile settings successfully updated.",
|
||||||
userProfileError: "Could not update user profile settings: {{error}}",
|
userProfileError: "Could not update user profile settings: {{error}}",
|
||||||
key: "Key",
|
|
||||||
value: "Value",
|
|
||||||
status: "Status",
|
status: "Status",
|
||||||
convertedToYearsValue: "{{convertedToYears}}",
|
convertedToYearsValue: "{{convertedToYears}}",
|
||||||
convertedToDaysValue: "{{convertedToDays}}",
|
convertedToDaysValue: "{{convertedToDays}}",
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import React, { useEffect } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useFieldArray, useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import {
|
import {
|
||||||
AlertVariant,
|
AlertVariant,
|
||||||
PageSection,
|
PageSection,
|
||||||
|
@ -11,31 +11,28 @@ import type UserRepresentation from "@keycloak/keycloak-admin-client/lib/defs/us
|
||||||
|
|
||||||
import { useAlerts } from "../components/alert/Alerts";
|
import { useAlerts } from "../components/alert/Alerts";
|
||||||
import {
|
import {
|
||||||
arrayToAttributes,
|
|
||||||
AttributeForm,
|
AttributeForm,
|
||||||
AttributesForm,
|
AttributesForm,
|
||||||
attributesToArray,
|
|
||||||
} from "../components/attribute-form/AttributeForm";
|
} from "../components/attribute-form/AttributeForm";
|
||||||
|
import {
|
||||||
|
attributesToArray,
|
||||||
|
arrayToAttributes,
|
||||||
|
} from "../components/attribute-form/attribute-convert";
|
||||||
import { useAdminClient } from "../context/auth/AdminClient";
|
import { useAdminClient } from "../context/auth/AdminClient";
|
||||||
|
|
||||||
type UserAttributesProps = {
|
type UserAttributesProps = {
|
||||||
user: UserRepresentation;
|
user: UserRepresentation;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const UserAttributes = ({ user }: UserAttributesProps) => {
|
export const UserAttributes = ({ user: defaultUser }: UserAttributesProps) => {
|
||||||
const { t } = useTranslation("users");
|
const { t } = useTranslation("users");
|
||||||
const adminClient = useAdminClient();
|
const adminClient = useAdminClient();
|
||||||
const { addAlert, addError } = useAlerts();
|
const { addAlert, addError } = useAlerts();
|
||||||
|
const [user, setUser] = useState<UserRepresentation>(defaultUser);
|
||||||
const form = useForm<AttributeForm>({ mode: "onChange" });
|
const form = useForm<AttributeForm>({ mode: "onChange" });
|
||||||
const { fields, append, remove } = useFieldArray({
|
|
||||||
control: form.control,
|
|
||||||
name: "attributes",
|
|
||||||
});
|
|
||||||
|
|
||||||
const convertAttributes = (attr?: Record<string, any>) => {
|
const convertAttributes = (attr?: Record<string, any>) => {
|
||||||
const attributes = attributesToArray(attr || user.attributes!);
|
return attributesToArray(attr || user.attributes!);
|
||||||
attributes.push({ key: "", value: "" });
|
|
||||||
return attributes;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -47,7 +44,7 @@ export const UserAttributes = ({ user }: UserAttributesProps) => {
|
||||||
const attributes = arrayToAttributes(attributeForm.attributes!);
|
const attributes = arrayToAttributes(attributeForm.attributes!);
|
||||||
await adminClient.users.update({ id: user.id! }, { ...user, attributes });
|
await adminClient.users.update({ id: user.id! }, { ...user, attributes });
|
||||||
|
|
||||||
form.setValue("attributes", convertAttributes(attributes));
|
setUser({ ...user, attributes });
|
||||||
addAlert(t("userSaved"), AlertVariant.success);
|
addAlert(t("userSaved"), AlertVariant.success);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
addError("groups:groupUpdateError", error);
|
addError("groups:groupUpdateError", error);
|
||||||
|
@ -59,7 +56,6 @@ export const UserAttributes = ({ user }: UserAttributesProps) => {
|
||||||
<AttributesForm
|
<AttributesForm
|
||||||
form={form}
|
form={form}
|
||||||
save={save}
|
save={save}
|
||||||
array={{ fields, append, remove }}
|
|
||||||
reset={() =>
|
reset={() =>
|
||||||
form.reset({
|
form.reset({
|
||||||
attributes: convertAttributes(),
|
attributes: convertAttributes(),
|
||||||
|
|
Loading…
Reference in a new issue