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 RoleMappingTab from "../support/pages/admin_console/manage/RoleMappingTab";
|
||||
import KeysTab from "../support/pages/admin_console/manage/clients/KeysTab";
|
||||
import AuthenticationTab from "../support/pages/admin_console/manage/clients/Authentication";
|
||||
|
||||
let itemId = "client_crud";
|
||||
const loginPage = new LoginPage();
|
||||
|
@ -426,4 +427,52 @@ describe("Clients test", () => {
|
|||
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";
|
||||
attributesTab
|
||||
.goToAttributesTab()
|
||||
.fillLastRow(attributeKey, "value")
|
||||
.addRow()
|
||||
.fillLastRow(attributeKey, "other value")
|
||||
.saveAttribute();
|
||||
|
||||
cy.wait("@save-user").should(({ request, response }) => {
|
||||
expect(response?.statusCode).to.equal(204);
|
||||
|
||||
expect(
|
||||
request?.body.attributes[attributeKey],
|
||||
"response body"
|
||||
).deep.equal(["value", "other value"]);
|
||||
expect(request?.body.attributes, "response body").deep.equal({
|
||||
key: ["value"],
|
||||
"key-multiple": ["other value"],
|
||||
});
|
||||
});
|
||||
|
||||
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() {
|
||||
cy.wait(100);
|
||||
cy.get("body").then((body) => {
|
||||
if (body.find("[data-testid=empty-state]").length > 0) {
|
||||
cy.findByTestId(this.emptyStateCreateUserBtn).click();
|
||||
} else if (body.find("[data-testid=search-users-title]").length > 0) {
|
||||
if (body.find("[data-testid=search-users-title]").length > 0) {
|
||||
cy.findByTestId(this.searchPgCreateUserBtn).click();
|
||||
} else {
|
||||
cy.findByTestId(this.addUserBtn).click();
|
||||
|
|
|
@ -8,6 +8,7 @@ import {
|
|||
} from "@patternfly/react-core";
|
||||
|
||||
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 "./detail-cell.css";
|
||||
|
@ -45,9 +46,13 @@ export const DetailCell = ({ id, clientId, uris }: DetailCellProps) => {
|
|||
},
|
||||
[]
|
||||
);
|
||||
|
||||
if (!permissions || !scope) {
|
||||
return <KeycloakSpinner />;
|
||||
}
|
||||
|
||||
return (
|
||||
<DescriptionList isHorizontal className="keycloak_resource_details">
|
||||
{uris?.length !== 0 && (
|
||||
<DescriptionListGroup>
|
||||
<DescriptionListTerm>{t("uris")}</DescriptionListTerm>
|
||||
<DescriptionListDescription>
|
||||
|
@ -56,35 +61,31 @@ export const DetailCell = ({ id, clientId, uris }: DetailCellProps) => {
|
|||
{uri}
|
||||
</span>
|
||||
))}
|
||||
{uris?.length === 0 && <i>{t("common:none")}</i>}
|
||||
</DescriptionListDescription>
|
||||
</DescriptionListGroup>
|
||||
)}
|
||||
{scope?.length !== 0 && (
|
||||
<DescriptionListGroup>
|
||||
<DescriptionListTerm>{t("scopes")}</DescriptionListTerm>
|
||||
<DescriptionListDescription>
|
||||
{scope?.map((scope) => (
|
||||
{scope.map((scope) => (
|
||||
<span key={scope.id} className="pf-u-pr-sm">
|
||||
{scope.name}
|
||||
</span>
|
||||
))}
|
||||
{scope.length === 0 && <i>{t("common:none")}</i>}
|
||||
</DescriptionListDescription>
|
||||
</DescriptionListGroup>
|
||||
)}
|
||||
{permissions?.length !== 0 && (
|
||||
<DescriptionListGroup>
|
||||
<DescriptionListTerm>
|
||||
{t("associatedPermissions")}
|
||||
</DescriptionListTerm>
|
||||
<DescriptionListTerm>{t("associatedPermissions")}</DescriptionListTerm>
|
||||
<DescriptionListDescription>
|
||||
{permissions?.map((permission) => (
|
||||
{permissions.map((permission) => (
|
||||
<span key={permission.id} className="pf-u-pr-sm">
|
||||
{permission.name}
|
||||
</span>
|
||||
))}
|
||||
{permissions.length === 0 && <i>{t("common:none")}</i>}
|
||||
</DescriptionListDescription>
|
||||
</DescriptionListGroup>
|
||||
)}
|
||||
</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 { Link } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Alert,
|
||||
AlertVariant,
|
||||
Button,
|
||||
Label,
|
||||
PageSection,
|
||||
Spinner,
|
||||
ToolbarItem,
|
||||
} from "@patternfly/react-core";
|
||||
import {
|
||||
ExpandableRowContent,
|
||||
|
@ -19,11 +21,15 @@ import {
|
|||
|
||||
import type ResourceServerRepresentation from "@keycloak/keycloak-admin-client/lib/defs/resourceServerRepresentation";
|
||||
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 { PaginatingTableToolbar } from "../../components/table-toolbar/PaginatingTableToolbar";
|
||||
import { useAdminClient, useFetch } from "../../context/auth/AdminClient";
|
||||
import { useAlerts } from "../../components/alert/Alerts";
|
||||
import { DetailCell } from "./DetailCell";
|
||||
import { toCreateResource } from "../routes/NewResource";
|
||||
import { useRealm } from "../../context/realm-context/RealmContext";
|
||||
import { toResourceDetails } from "../routes/Resource";
|
||||
|
||||
type ResourcesProps = {
|
||||
clientId: string;
|
||||
|
@ -37,6 +43,8 @@ export const AuthorizationResources = ({ clientId }: ResourcesProps) => {
|
|||
const { t } = useTranslation("clients");
|
||||
const adminClient = useAdminClient();
|
||||
const { addAlert, addError } = useAlerts();
|
||||
const { realm } = useRealm();
|
||||
|
||||
const [resources, setResources] =
|
||||
useState<ExpandableResourceRepresentation[]>();
|
||||
const [selectedResource, setSelectedResource] =
|
||||
|
@ -127,7 +135,7 @@ export const AuthorizationResources = ({ clientId }: ResourcesProps) => {
|
|||
});
|
||||
|
||||
if (!resources) {
|
||||
return <Spinner />;
|
||||
return <KeycloakSpinner />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
@ -143,6 +151,21 @@ export const AuthorizationResources = ({ clientId }: ResourcesProps) => {
|
|||
setFirst(first);
|
||||
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">
|
||||
<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.owner?.name}</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,
|
||||
PageSection,
|
||||
Radio,
|
||||
Spinner,
|
||||
Switch,
|
||||
} from "@patternfly/react-core";
|
||||
|
||||
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 { FormAccess } from "../../components/form-access/FormAccess";
|
||||
import { HelpItem } from "../../components/help-enabler/HelpItem";
|
||||
|
@ -43,7 +43,7 @@ export const AuthorizationSettings = ({ clientId }: { clientId: string }) => {
|
|||
);
|
||||
|
||||
if (!resource) {
|
||||
return <Spinner />;
|
||||
return <KeycloakSpinner />;
|
||||
}
|
||||
|
||||
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.",
|
||||
allowRemoteResourceManagement:
|
||||
"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",
|
||||
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",
|
||||
owner: "Owner",
|
||||
uris: "URIs",
|
||||
|
@ -73,8 +87,6 @@ export default {
|
|||
"The permissions below will be removed when they are no longer used by other resources:",
|
||||
resourceDeletedSuccess: "The resource successfully deleted",
|
||||
resourceDeletedError: "Could not remove the resource {{error}}",
|
||||
associatedPermissions: "Associated permission",
|
||||
allowRemoteResourceManagement: "Remote resource management",
|
||||
assignedClientScope: "Assigned client scope",
|
||||
assignedType: "Assigned type",
|
||||
hideInheritedRoles: "Hide inherited roles",
|
||||
|
|
|
@ -5,6 +5,8 @@ import { ClientsRoute } from "./routes/Clients";
|
|||
import { CreateInitialAccessTokenRoute } from "./routes/CreateInitialAccessToken";
|
||||
import { ImportClientRoute } from "./routes/ImportClient";
|
||||
import { MapperRoute } from "./routes/Mapper";
|
||||
import { NewResourceRoute } from "./routes/NewResource";
|
||||
import { ResourceDetailsRoute } from "./routes/Resource";
|
||||
|
||||
const routes: RouteDef[] = [
|
||||
AddClientRoute,
|
||||
|
@ -13,6 +15,8 @@ const routes: RouteDef[] = [
|
|||
CreateInitialAccessTokenRoute,
|
||||
ClientRoute,
|
||||
MapperRoute,
|
||||
NewResourceRoute,
|
||||
ResourceDetailsRoute,
|
||||
];
|
||||
|
||||
export default routes;
|
||||
|
|
|
@ -8,7 +8,8 @@ export type ClientTab =
|
|||
| "roles"
|
||||
| "clientScopes"
|
||||
| "advanced"
|
||||
| "mappers";
|
||||
| "mappers"
|
||||
| "authorization";
|
||||
|
||||
export type ClientParams = {
|
||||
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",
|
||||
remove: "Remove",
|
||||
search: "Search",
|
||||
key: "Key",
|
||||
value: "Value",
|
||||
noSearchResults: "No search results",
|
||||
noSearchResultsInstructions: "Click on the search bar above to search",
|
||||
next: "Next",
|
||||
|
|
|
@ -1,24 +1,13 @@
|
|||
import React, { useEffect } from "react";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ArrayField, UseFormMethods } from "react-hook-form";
|
||||
import { ActionGroup, 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 { FormProvider, UseFormMethods } from "react-hook-form";
|
||||
import { ActionGroup, Button } from "@patternfly/react-core";
|
||||
|
||||
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 "./attribute-form.css";
|
||||
|
||||
export type KeyValueType = { key: string; value: string };
|
||||
|
||||
export type AttributeForm = Omit<RoleRepresentation, "attributes"> & {
|
||||
attributes?: KeyValueType[];
|
||||
};
|
||||
|
@ -27,197 +16,35 @@ export type AttributesFormProps = {
|
|||
form: UseFormMethods<AttributeForm>;
|
||||
save?: (model: AttributeForm) => 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[]) => {
|
||||
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) => {
|
||||
export const AttributesForm = ({ form, reset, save }: AttributesFormProps) => {
|
||||
const { t } = useTranslation("roles");
|
||||
|
||||
const columns = ["Key", "Value"];
|
||||
|
||||
const noSaveCancelButtons = !save && !reset;
|
||||
|
||||
const watchLast = inConfig
|
||||
? watch(`config.attributes[${fields.length - 1}].key`, "")
|
||||
: watch(`attributes[${fields.length - 1}].key`, "");
|
||||
|
||||
useEffect(() => {
|
||||
if (fields.length === 0) {
|
||||
append({ key: "", value: "" });
|
||||
}
|
||||
}, [fields]);
|
||||
const {
|
||||
formState: { isDirty },
|
||||
handleSubmit,
|
||||
} = form;
|
||||
|
||||
return (
|
||||
<FormAccess
|
||||
role="manage-realm"
|
||||
onSubmit={save ? handleSubmit(save) : undefined}
|
||||
>
|
||||
<TableComposable
|
||||
className="kc-attributes__table"
|
||||
aria-label="Role attribute keys and values"
|
||||
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>
|
||||
<FormProvider {...form}>
|
||||
<AttributeInput name="attributes" />
|
||||
</FormProvider>
|
||||
{!noSaveCancelButtons && (
|
||||
<ActionGroup className="kc-attributes__action-group">
|
||||
<Button
|
||||
data-testid="save-attributes"
|
||||
variant="primary"
|
||||
type="submit"
|
||||
isDisabled={!watchLast}
|
||||
isDisabled={!isDirty}
|
||||
>
|
||||
{t("common:save")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={reset}
|
||||
variant="link"
|
||||
isDisabled={!formState.isDirty}
|
||||
>
|
||||
<Button onClick={reset} variant="link" isDisabled={!isDirty}>
|
||||
{t("common:revert")}
|
||||
</Button>
|
||||
</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 { useTranslation } from "react-i18next";
|
||||
import { useFieldArray, useForm } from "react-hook-form";
|
||||
import { useForm } from "react-hook-form";
|
||||
import {
|
||||
AlertVariant,
|
||||
PageSection,
|
||||
|
@ -9,11 +9,13 @@ import {
|
|||
|
||||
import { useAlerts } from "../components/alert/Alerts";
|
||||
import {
|
||||
arrayToAttributes,
|
||||
AttributeForm,
|
||||
AttributesForm,
|
||||
attributesToArray,
|
||||
} from "../components/attribute-form/AttributeForm";
|
||||
import {
|
||||
arrayToAttributes,
|
||||
attributesToArray,
|
||||
} from "../components/attribute-form/attribute-convert";
|
||||
import { useAdminClient } from "../context/auth/AdminClient";
|
||||
|
||||
import { getLastId } from "./groupIdUtils";
|
||||
|
@ -24,10 +26,9 @@ export const GroupAttributes = () => {
|
|||
const { t } = useTranslation("groups");
|
||||
const adminClient = useAdminClient();
|
||||
const { addAlert, addError } = useAlerts();
|
||||
const form = useForm<AttributeForm>({ mode: "onChange" });
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control: form.control,
|
||||
name: "attributes",
|
||||
const form = useForm<AttributeForm>({
|
||||
mode: "onChange",
|
||||
shouldUnregister: false,
|
||||
});
|
||||
|
||||
const location = useLocation();
|
||||
|
@ -35,9 +36,7 @@ export const GroupAttributes = () => {
|
|||
const { currentGroup, subGroups, setSubGroups } = useSubGroups();
|
||||
|
||||
const convertAttributes = (attr?: Record<string, any>) => {
|
||||
const attributes = attributesToArray(attr || currentGroup().attributes!);
|
||||
attributes.push({ key: "", value: "" });
|
||||
return attributes;
|
||||
return attributesToArray(attr || currentGroup().attributes!);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -54,7 +53,6 @@ export const GroupAttributes = () => {
|
|||
...subGroups.slice(0, subGroups.length - 1),
|
||||
{ ...group, attributes },
|
||||
]);
|
||||
form.setValue("attributes", convertAttributes(attributes));
|
||||
addAlert(t("groupUpdated"), AlertVariant.success);
|
||||
} catch (error) {
|
||||
addError("groups:groupUpdateError", error);
|
||||
|
@ -66,7 +64,6 @@ export const GroupAttributes = () => {
|
|||
<AttributesForm
|
||||
form={form}
|
||||
save={save}
|
||||
array={{ fields, append, remove }}
|
||||
reset={() =>
|
||||
form.reset({
|
||||
attributes: convertAttributes(),
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import React, { useMemo, useState } from "react";
|
||||
import { Link, useHistory, useParams } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Controller, useFieldArray, useForm } from "react-hook-form";
|
||||
import { Controller, FormProvider, useForm } from "react-hook-form";
|
||||
import {
|
||||
ActionGroup,
|
||||
AlertVariant,
|
||||
|
@ -20,10 +20,8 @@ import { useRealm } from "../../context/realm-context/RealmContext";
|
|||
|
||||
import { HelpItem } from "../../components/help-enabler/HelpItem";
|
||||
import { ViewHeader } from "../../components/view-header/ViewHeader";
|
||||
import {
|
||||
AttributeForm,
|
||||
AttributesForm,
|
||||
} from "../../components/attribute-form/AttributeForm";
|
||||
import { AttributeInput } from "../../components/attribute-input/AttributeInput";
|
||||
import type { AttributeForm } from "../../components/attribute-form/AttributeForm";
|
||||
import { FormAccess } from "../../components/form-access/FormAccess";
|
||||
import { useAdminClient, useFetch } from "../../context/auth/AdminClient";
|
||||
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(
|
||||
() =>
|
||||
Promise.all([
|
||||
|
@ -177,10 +170,6 @@ export default function AddMapper() {
|
|||
);
|
||||
}
|
||||
|
||||
if (mapper.config?.attribute) {
|
||||
form.setValue("config.attributes", value.attribute);
|
||||
}
|
||||
|
||||
if (mapper.config?.attributes) {
|
||||
form.setValue("config.attributes", JSON.parse(value.attributes));
|
||||
}
|
||||
|
@ -344,11 +333,9 @@ export default function AddMapper() {
|
|||
}
|
||||
fieldId="kc-gui-order"
|
||||
>
|
||||
<AttributesForm
|
||||
form={form}
|
||||
inConfig
|
||||
array={{ fields, append, remove }}
|
||||
/>
|
||||
<FormProvider {...form}>
|
||||
<AttributeInput name="config.attributes" />
|
||||
</FormProvider>
|
||||
</FormGroup>
|
||||
<FormGroup
|
||||
label={
|
||||
|
|
|
@ -9,7 +9,7 @@ import {
|
|||
TabTitleText,
|
||||
} from "@patternfly/react-core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useFieldArray, useForm } from "react-hook-form";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { omit } from "lodash";
|
||||
|
||||
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 {
|
||||
AttributesForm,
|
||||
attributesToArray,
|
||||
arrayToAttributes,
|
||||
AttributeForm,
|
||||
} from "../components/attribute-form/AttributeForm";
|
||||
import {
|
||||
attributesToArray,
|
||||
arrayToAttributes,
|
||||
} from "../components/attribute-form/attribute-convert";
|
||||
import { KeycloakSpinner } from "../components/keycloak-spinner/KeycloakSpinner";
|
||||
import { ViewHeader } from "../components/view-header/ViewHeader";
|
||||
import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog";
|
||||
|
@ -43,7 +45,7 @@ export default function RealmRoleTabs() {
|
|||
const form = useForm<AttributeForm>({
|
||||
mode: "onChange",
|
||||
});
|
||||
const { control, setValue, getValues, trigger, reset } = form;
|
||||
const { setValue, getValues, trigger, reset } = form;
|
||||
const history = useHistory();
|
||||
|
||||
const adminClient = useAdminClient();
|
||||
|
@ -101,11 +103,6 @@ export default function RealmRoleTabs() {
|
|||
[key]
|
||||
);
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control,
|
||||
name: "attributes",
|
||||
});
|
||||
|
||||
const save = async () => {
|
||||
try {
|
||||
const values = getValues();
|
||||
|
@ -379,7 +376,6 @@ export default function RealmRoleTabs() {
|
|||
<AttributesForm
|
||||
form={form}
|
||||
save={save}
|
||||
array={{ fields, append, remove }}
|
||||
reset={() => reset(role)}
|
||||
/>
|
||||
</Tab>
|
||||
|
|
|
@ -346,8 +346,6 @@ export default {
|
|||
"Unable to save user profile, the provided information is not valid JSON.",
|
||||
userProfileSuccess: "User profile settings successfully updated.",
|
||||
userProfileError: "Could not update user profile settings: {{error}}",
|
||||
key: "Key",
|
||||
value: "Value",
|
||||
status: "Status",
|
||||
convertedToYearsValue: "{{convertedToYears}}",
|
||||
convertedToDaysValue: "{{convertedToDays}}",
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import React, { useEffect } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useFieldArray, useForm } from "react-hook-form";
|
||||
import { useForm } from "react-hook-form";
|
||||
import {
|
||||
AlertVariant,
|
||||
PageSection,
|
||||
|
@ -11,31 +11,28 @@ import type UserRepresentation from "@keycloak/keycloak-admin-client/lib/defs/us
|
|||
|
||||
import { useAlerts } from "../components/alert/Alerts";
|
||||
import {
|
||||
arrayToAttributes,
|
||||
AttributeForm,
|
||||
AttributesForm,
|
||||
attributesToArray,
|
||||
} from "../components/attribute-form/AttributeForm";
|
||||
import {
|
||||
attributesToArray,
|
||||
arrayToAttributes,
|
||||
} from "../components/attribute-form/attribute-convert";
|
||||
import { useAdminClient } from "../context/auth/AdminClient";
|
||||
|
||||
type UserAttributesProps = {
|
||||
user: UserRepresentation;
|
||||
};
|
||||
|
||||
export const UserAttributes = ({ user }: UserAttributesProps) => {
|
||||
export const UserAttributes = ({ user: defaultUser }: UserAttributesProps) => {
|
||||
const { t } = useTranslation("users");
|
||||
const adminClient = useAdminClient();
|
||||
const { addAlert, addError } = useAlerts();
|
||||
const [user, setUser] = useState<UserRepresentation>(defaultUser);
|
||||
const form = useForm<AttributeForm>({ mode: "onChange" });
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control: form.control,
|
||||
name: "attributes",
|
||||
});
|
||||
|
||||
const convertAttributes = (attr?: Record<string, any>) => {
|
||||
const attributes = attributesToArray(attr || user.attributes!);
|
||||
attributes.push({ key: "", value: "" });
|
||||
return attributes;
|
||||
return attributesToArray(attr || user.attributes!);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -47,7 +44,7 @@ export const UserAttributes = ({ user }: UserAttributesProps) => {
|
|||
const attributes = arrayToAttributes(attributeForm.attributes!);
|
||||
await adminClient.users.update({ id: user.id! }, { ...user, attributes });
|
||||
|
||||
form.setValue("attributes", convertAttributes(attributes));
|
||||
setUser({ ...user, attributes });
|
||||
addAlert(t("userSaved"), AlertVariant.success);
|
||||
} catch (error) {
|
||||
addError("groups:groupUpdateError", error);
|
||||
|
@ -59,7 +56,6 @@ export const UserAttributes = ({ user }: UserAttributesProps) => {
|
|||
<AttributesForm
|
||||
form={form}
|
||||
save={save}
|
||||
array={{ fields, append, remove }}
|
||||
reset={() =>
|
||||
form.reset({
|
||||
attributes: convertAttributes(),
|
||||
|
|
Loading…
Reference in a new issue