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

View file

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

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

View file

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

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

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

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

View file

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

View file

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

View file

@ -8,7 +8,8 @@ export type ClientTab =
| "roles"
| "clientScopes"
| "advanced"
| "mappers";
| "mappers"
| "authorization";
export type ClientParams = {
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",
remove: "Remove",
search: "Search",
key: "Key",
value: "Value",
noSearchResults: "No search results",
noSearchResultsInstructions: "Click on the search bar above to search",
next: "Next",

View file

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

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

View file

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

View file

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

View file

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

View file

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