8000 Add organization picker to FGAP permissions in Admin UI by UCNDanny · Pull Request #53203 · keycloak/keycloak · GitHub
[go: up one dir, main page]

Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,12 @@ set of management operations:

Creating new organizations requires `manage` permission on all organizations (the resource type level), not just on a specific organization.

To apply a permission to particular organizations, select *Specific Organizations* when creating the permission and pick them from the list.
With *All Organizations*, the permission applies to every organization in the realm.

A realm administrator who only has permissions on specific organizations also needs the `query-organizations` role to see and list them in the administration console.
For more details on these roles, see <<_realm_access_control, Accessing a Realm administration console as a Realm Administrator>>.

==== Enabling admin permissions to a realm

To enable fine-grained admin permissions in a realm, follow these steps:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3963,6 +3963,9 @@ authorizationScope.Roles.map-role=Maps this role to a user or group
usersResources=Users
clientsResources=Clients
groupsResources=Groups
organizationsResources=Organizations
selectOrganization=Select organization
selectOrganizations=Select organizations
resourceTypeHelpText=Specifies which {{resourceType}} are allowed by this permission. If left blank, it is the same as selecting all.
evaluation=Evaluation
addSubFlowTo=Add sub-flow to {{name}}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ interface EvaluateFormInputs
groups: string[];
users: string[];
roles: string[];
organizations: string[];
resourceType?: string;
}

Expand Down Expand Up @@ -114,6 +115,8 @@ const PermissionEvaluateContent = ({ client }: Props) => {
return getSingleValue(formValues.clients);
case "Roles":
return getSingleValue(formValues.roles);
case "Organizations":
return getSingleValue(formValues.organizations);
Comment thread
UCNDanny marked this conversation as resolved.
Resolved
default:
return undefined;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
import type OrganizationRepresentation from "@keycloak/keycloak-admin-client/lib/defs/organizationRepresentation";
import {
FormErrorText,
HelpItem,
KeycloakDataTable,
ListEmptyState,
useFetch,
} from "@keycloak/keycloak-ui-shared";
import { Button, FormGroup, Modal, ModalVariant } from "@patternfly/react-core";
import { MinusCircleIcon } from "@patternfly/react-icons";
import { Table, Tbody, Td, Th, Thead, Tr } from "@patternfly/react-table";
import { useState } from "react";
import { Controller, useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { useAdminClient } from "../../admin-client";
import type { ComponentProps } from "../../components/dynamic/components";

type OrganizationSelectProps = Omit<ComponentProps, "convertToName"> & {
variant?: "typeahead" | "typeaheadMulti";
isRequired?: boolean;
};

export const OrganizationSelect = ({
name,
label,
helpText,
defaultValue,
isDisabled = false,
isRequired,
variant = "typeaheadMulti",
}: OrganizationSelectProps) => {
const { adminClient } = useAdminClient();
const { t } = useTranslation();
const {
control,
setValue,
getValues,
formState: { errors },
} = useFormContext();
const [open, setOpen] = useState(false);
const [selected, setSelected] = useState<OrganizationRepresentation[]>([]);
const [picked, setPicked] = useState<OrganizationRepresentation[]>([]);
const selectOne = variant === "typeahead";

useFetch(
() => {
const v = getValues(name!) ?? defaultValue;
const values: string[] = typeof v === "string" ? (v ? [v] : []) : v || [];
if (values.length > 0) {
return Promise.all(
values.map((id) => adminClient.organizations.findOne({ id })),
);
}
return Promise.resolve([]);
},
(orgs) => setSelected(orgs),
[],
);

const loader = async (first?: number, max?: number, search?: string) =>
adminClient.organizations.find({ first, max, search });

return (
<FormGroup
label={t(label!)}
labelIcon={
helpText ? (
<HelpItem helpText={t(helpText)} fieldLabelId="organizations" />
) : undefined
}
fieldId={name}
isRequired={isRequired}
>
<Controller
name={name!}
control={control}
defaultValue={defaultValue}
rules={{
validate: (value) => {
if (!isRequired) return true;
if (Array.isArray(value)) return value.length > 0;
return !!value;
},
}}
render={({ field }) => (
<>
{open && (
<Modal
data-testid="select-organization-modal"
variant={ModalVariant.large}
title={t("selectOrganization")}
isOpen
=> setOpen(false)}
actions={[
<Button
data-testid="confirm"
key="confirm"
variant="primary"
isDisabled={picked.length === 0}
=> {
const ids = picked.map(({ id }) => id!);
if (selectOne) {
field.onChange(ids[0]);
setSelected(picked.slice(0, 1));
} else {
const existing: string[] = field.value ?? [];
const fresh = picked.filter(
({ id }) => !existing.includes(id!),
);
field.onChange([
...existing,
...fresh.map((o) => o.id),
]);
setSelected([...selected, ...fresh]);
}
setOpen(false);
}}
>
{t("add")}
</Button>,
<Button
data-testid="cancel"
key="cancel"
variant="link"
=> setOpen(false)}
>
{t("cancel")}
</Button>,
]}
>
<KeycloakDataTable
=> setPicked([...rows])}
searchPlaceholderKey="searchOrganization"
isPaginated
canSelectAll={!selectOne}
isRadio={selectOne}
loader={loader}
ariaLabelKey="organizations"
columns={[{ name: "name" }, { name: "alias" }]}
emptyState={
<ListEmptyState
message={t("emptyOrganizations")}
instructions={t("emptyOrganizationsInstructions")}
/>
}
/>
Comment thread
Copilot marked this conversation as resolved.
Resolved
</Modal>
)}
<Button
data-testid="select-organization-button"
variant="secondary"
isDisabled={isDisabled}
=> {
setPicked([]);
setOpen(true);
}}
>
{t("selectOrganization")}
</Button>
</>
)}
/>
{selected.length > 0 && (
<Table variant="compact">
<Thead>
<Tr>
<Th>{t("organization")}</Th>
<Th aria-hidden="true" />
</Tr>
</Thead>
<Tbody>
{selected.map((org) => (
<Tr key={org.id}>
<Td>{org.name}</Td>
<Td>
<Button
variant="link"
className="keycloak__client-authorization__policy-row-remove"
aria-label={t("remove")}
icon={<MinusCircleIcon />}
=> {
const remaining = selected.filter((o) => o.id !== org.id);
setSelected(remaining);
setValue(
name!,
selectOne ? "" : remaining.map((o) => o.id),
{ shouldDirty: true },
);
}}
/>
</Td>
</Tr>
))}
</Tbody>
</Table>
)}
{errors[name!] && <FormErrorText message={t("required")} />}
</FormGroup>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { GroupSelect } from "./GroupSelect";
import { UserSelect } from "../../components/users/UserSelect";
import { RoleSelect } from "./RoleSelect";
import { ClientSelectComponent } from "./ClientSelectComponent";
import { OrganizationSelect } from "./OrganizationSelect";

type ResourceTypeProps = {
withEnforceAccessTo?: boolean;
Expand All @@ -20,6 +21,7 @@ export const COMPONENTS: {
clients: ClientSelectComponent,
groups: GroupSelect,
roles: RoleSelect,
organizations: OrganizationSelect,
Comment thread
UCNDanny marked this conversation as resolved.
Resolved
Comment thread
UCNDanny marked this conversation as resolved.
Resolved
} as const;

export const isValidComponentType = (value: string) => value in COMPONENTS;
Expand Down
16 changes: 14 additions & 2 deletions js/apps/admin-ui/src/utils/useSortedResourceTypes.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import ResourceServerRepresentation from "@keycloak/keycloak-admin-client/lib/defs/resourceServerRepresentation";
import { useMemo, useState } from "react";
import { useAdminClient } from "../admin-client";
import { useRealm } from "../context/realm-context/RealmContext";
import useIsFeatureEnabled, { Feature } from "./useIsFeatureEnabled";
import { useFetch } from "@keycloak/keycloak-ui-shared";
import { sortBy } from "lodash-es";

Expand All @@ -12,6 +14,11 @@ export default function useSortedResourceTypes({
clientId,
}: UseSortedResourceTypesProps) {
const { adminClient } = useAdminClient();
const { realmRepresentation } = useRealm();
const isFeatureEnabled = useIsFeatureEnabled();
const organizationsAvailable =
isFeatureEnabled(Feature.Organizations) &&
realmRepresentation.organizationsEnabled;
const [resourceServer, setResourceServer] =
useState<ResourceServerRepresentation>();

Expand All @@ -27,9 +34,14 @@ export default function useSortedResourceTypes({
const resourceTypes = useMemo(() => {
const allResourceTypes = resourceServer?.authorizationSchema?.resourceTypes;
return allResourceTypes
? sortBy(Object.values(allResourceTypes), "type")
? sortBy(
Object.values(allResourceTypes).filter(
({ type }) => type !== "Organizations" || organizationsAvailable,
),
"type",
)
: [];
}, [resourceServer]);
}, [resourceServer, organizationsAvailable]);

return resourceTypes;
}
Loading
0