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:
Erik Jan de Wit 2021-12-01 08:58:25 +01:00 committed by GitHub
parent 93ee81b6af
commit 0bbd4ddad1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 1010 additions and 301 deletions

View file

@ -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");
});
});
}); });

View file

@ -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");

View file

@ -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;
}
}

View file

@ -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();

View file

@ -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>
); );
}; };

View 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>
</>
);
}

View file

@ -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>

View 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>
);
};

View file

@ -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 (

View file

@ -0,0 +1,4 @@
.keycloak__resource-details__form {
--pf-c-form--m-horizontal__group-label--md--GridColumnWidth: 10rem;
}

View file

@ -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.",
}, },
}; };

View file

@ -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",

View file

@ -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;

View file

@ -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;

View 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),
});

View 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),
});

View file

@ -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",

View file

@ -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>

View 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: "" },
]);
});
});

View 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: "" });
};

View 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>
);
};

View file

@ -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(),

View file

@ -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={

View file

@ -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>

View file

@ -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}}",

View file

@ -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(),