8000 Implement selecting specific organizations in FGAP permission by theohh0 · Pull Request #51717 · keycloak/keycloak · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
69 commits
Select commit Hold shift + click to select a range
0407296
Safely Adapted organization modal for use with fgap
theohh0 Aug 13, 2026
66a947f
Create OrganizationSelect component
theohh0 Aug 13, 2026
85f1d26
Add organizations to resource types
theohh0 Aug 13, 2026
182f654
Added en translation keys
theohh0 Aug 13, 2026
de3b91e
added orgs to permission eval
theohh0 Aug 13, 2026
99af335
Show error when no orgs selected
theohh0 Aug 17, 2026
fe82415
disable add/join button if no orgs selected
theohh0 Aug 17, 2026
b074544
display error message when no organizations exist
theohh0 Aug 17, 2026
0ef99c1
filter out organization resource type if disabled
theohh0 Aug 18, 2026
ce91a27
Catch not found organizations
theohh0 Aug 18, 2026
63fb8da
pass isRadio through to organization modal
theohh0 Aug 18, 2026
0292c3a
Clean up organization modal titles code
theohh0 Aug 18, 2026
95b9c78
casing and placement
theohh0 Aug 18, 2026
52977d3
Don't show empty orgs error when orgs loading.
theohh0 Aug 24, 2026
3db6683
Disable existing orgs selected rather than removing them from modal
theohh0 Aug 24, 2026
3e9519a
Select all shouldnt select disabled rows in `KeycloakDataTable`
theohh0 Aug 24, 2026
f322af7
fix adminclient org typing and catch nulls
theohh0 Aug 24, 2026
b58ee47
use translation keys for OrganizationModal.tsx titles
theohh0 Aug 25, 2026
45c827c
Missing message keys
theohh0 Aug 25, 2026
c53c51d
Calculate orgsEnabled outside of memo
theohh0 Aug 25, 2026
dd7d0e8
Don't include subrows in select all
theohh0 Aug 25, 2026
fc792ee
remove unnecessary properties from SubRow
theohh0 Aug 25, 2026
aa19abf
disable select all if nothing selectable
theohh0 Aug 25, 2026
f15306a
don't need to pass existingOrgs length into loader params now that th…
theohh0 Aug 26, 2026
f5be7ea
added select/delete org permission test
theohh0 Aug 26, 2026
ed4be68
enable organizations in test realm
theohh0 Aug 27, 2026
0fded8e
reusable selectedRows var
theohh0 Aug 27, 2026
313591b
hopefully more concrete org row selector
theohh0 Aug 27, 2026
8bd6f1c
create policy with different name
theohh0 Aug 27, 2026
ef5bf38
just check for presence of data property - not truthiness
8000 theohh0 Aug 27, 2026
ee7f6ec
clean up perm tests once complete
theohh0 Aug 28, 2026
208e112
Safely Adapted organization modal for use with fgap
theohh0 Aug 13, 2026
8dd9b80
Create OrganizationSelect component
theohh0 Aug 13, 2026
c542dda
Add organizations to resource types
theohh0 Aug 13, 2026
f081ae2
Added en translation keys
theohh0 Aug 13, 2026
92a247f
added orgs to permission eval
theohh0 Aug 13, 2026
2713237
Show error when no orgs selected
theohh0 Aug 17, 2026
dd3002a
disable add/join button if no orgs selected
theohh0 Aug 17, 2026
9b9d01d
display error message when no organizations exist
theohh0 Aug 17, 2026
4036982
filter out organization resource type if disabled
theohh0 Aug 18, 2026
bfddda6
Catch not found organizations
theohh0 Aug 18, 2026
b2cbb3f
pass isRadio through to organization modal
theohh0 Aug 18, 2026
58ea82d
Clean up organization modal titles code
theohh0 Aug 18, 2026
8a4e925
casing and placement
theohh0 Aug 18, 2026
2903158
Don't show empty orgs error when orgs loading.
theohh0 Aug 24, 2026
b4452b7
Disable existing orgs selected rather than removing them from modal
theohh0 Aug 24, 2026
cfecd20
Select all shouldnt select disabled rows in `KeycloakDataTable`
theohh0 Aug 24, 2026
8109afd
fix adminclient org typing and catch nulls
theohh0 Aug 24, 2026
60e6aac
use translation keys for OrganizationModal.tsx titles
theohh0 Aug 25, 2026
0dc5936
Missing message keys
theohh0 Aug 25, 2026
ab68248
Calculate orgsEnabled outside of memo
theohh0 Aug 25, 2026
176c4eb
Don't include subrows in select all
theohh0 Aug 25, 2026
60bf891
remove unnecessary properties from SubRow
theohh0 Aug 25, 2026
5f4b697
disable select all if nothing selectable
theohh0 Aug 25, 2026
b90f8e3
don't need to pass existingOrgs length into loader params now that th…
theohh0 Aug 26, 2026
806e348
added select/delete org permission test
theohh0 Aug 26, 2026
3170684
enable organizations in test realm
theohh0 Aug 27, 2026
90d9f30
reusable selectedRows var
theohh0 Aug 27, 2026
25897c0
hopefully more concrete org row selector
theohh0 Aug 27, 2026
c39f92a
create policy with different name
theohh0 Aug 27, 2026
cc35527
just check for presence of data property - not truthiness
theohh0 Aug 27, 2026
8430850
clean up perm tests once complete
theohh0 Aug 28, 2026
2caccc3
Merge branch 'select-org-fgap2' of https://github.com/theohh0/keycloa…
theohh0 Aug 31, 2026
3e53d55
Make sure delete completes
theohh0 Sep 8, 2026
a86836d
disable row selection only when var exists and true in KeycloakDataTable
theohh0 Sep 24, 2026
d83e94f
corrected copilots edit
theohh0 Sep 24, 2026
97fd06f
Merge branch 'main' into select-org-fgap2
theohh0 Sep 24, 2026
930f35c
Handle undefined organization in findOne callers
UCNDanny Sep 29, 2026
474e96e
use isRadio to manage evaluation 'canSelectAll' so column headings do…
theohh0 Sep 29, 2026
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 @@ -3958,6 +3958,7 @@ authorizationScope.Roles.map-role=Maps this role to a user or group
usersResources=Users
clientsResources=Clients
groupsResources=Groups
organizationsResources=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 Expand Up @@ -4219,6 +4220,13 @@ showOnLoginForUnlinkedMembers=Show on login page for unlinked members
showOnLoginForUnlinkedMembersHelp=If enabled, this identity provider is shown to organization members even when they are already linked to another identity provider.
theme.keycloak.v2.admin.description=Cleaner and more modern, v2 supports automatic light/dark mode switching. (Default for Admin)

# FGAP Organization Select

addOrganizations=Add organizations
selectOrganization=Select organization
selectOrganizations=Select organization
requiredOrganizations=Please add at least one organization.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Casing and placement are sorted now, thanks. Two key issues left, both making the UI show raw keys:

selectOrganization is never read, and the key that is read doesn't exist. The evaluation tab builds the help key as t(`select${selectedResourceType}`) (PermissionsEvaluationTab.tsx:203), and the resource type is Organizations, so it looks up selectOrganizations — plural. Only the singular is defined here, so the popover renders the literal string selectOrganizations. Either add the plural key, or point the modal title at the singular one (see my reply on the TITLES thread, which would give the singular a consumer).

requiredOrganizations is missing. OrganizationSelect.tsx:164 renders FormErrorText with t("requiredOrganizations") and there's no such key, so pedroigor's validation message — the thing this PR added FormErrorText for — currently displays as requiredOrganizations.

On your question in the PR thread about requiredGroups: agreed, that one is pre-existing and a separate PR is the right call. requiredOrganizations is different though — it's new in this PR and it's the only thing standing between the validation fix and it actually working, so it should land here. For what it's worth selectRoles is missing too, so the plural-key convention is already patchy in main; worth folding into the same follow-up as requiredGroups if you pick it up.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can add the plural key for selectOrganizations but I would note that perhaps (for all the resource types) using the plural doesnt make sense (i think some say just "select user" but others like clients say "select clients") as for the permission evaluation you are only selecting one resource.

Also in checking this I've noticed a bug (which I've verified is on main and not just my branch) where if you select the roles resource when evaluating permissions, it crashes saying "Cannot read properties of undefined (reading 'length')"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Singular or plural is fine for the text — just use what you think makes sense. Note the key name itself isn't optional though: PermissionsEvaluationTab.tsx:200 does t(`select${selectedResourceType}`), so it has to be selectOrganizations even if the text reads "Select organization". selectUsers=Select user already does exactly that.

We mainly don't want to have "not found" localization keys. On that note — selectRoles is missing entirely, so Roles already shows a raw key on main. Worth rolling into the same fix as the Roles evaluation crash you found.

# Roles
role_admin=Admin
role_realm-admin=Realm Admin
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export const IdpDomainsTab = ({ alias }: IdpDomainsTabProps) => {
const org = await adminClient.organizations.findOne({
id: link.organizationId!,
});
if (!org) return [];
return (org.domains ?? [])
.filter((d) => d.identityProviderAlias === alias)
.map((d) => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export const IdpOrganizationsTab = ({
const org = await adminClient.organizations.findOne({
id: link.organizationId!,
});
if (!org) return null;
return {
...org,
...link,
Expand Down
6 changes: 3 additions & 3 deletions js/apps/admin-ui/src/organizations/DomainsTab.tsx
10BC8
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ const DomainModal = ({ orgId, domain, onClose }: DomainModalProps) => {
const submitForm = async (data: OrganizationDomainRepresentation) => {
try {
const org = await adminClient.organizations.findOne({ id: orgId });
const domains = org.domains ?? [];
const domains = org?.domains ?? [];
const payload = {
verified: data.verified,
autoRedirect: data.autoRedirect,
Expand Down Expand Up @@ -178,7 +178,7 @@ export const DomainsTab = () => {

const loader = async () => {
const org = await adminClient.organizations.findOne({ id: orgId! });
return org.domains ?? [];
return org?.domains ?? [];
};

const [toggleDeleteDialog, DeleteConfirm] = useConfirmDialog({
Expand All @@ -189,7 +189,7 @@ export const DomainsTab = () => {
onConfirm: async () => {
try {
const org = await adminClient.organizations.findOne({ id: orgId! });
const domains = (org.domains ?? []).filter(
const domains = (org?.domains ?? []).filter(
(d) => d.name !== selectedDomain?.name,
);
await adminClient.organizations.updateById(
Expand Down
45 changes: 36 additions & 9 deletions js/apps/admin-ui/src/organizations/OrganizationModal.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,35 @@
import OrganizationRepresentation from "@keycloak/keycloak-admin-client/lib/defs/organizationRepresentation";
import UserRepresentation from "@keycloak/keycloak-admin-client/lib/defs/userRepresentation";
Comment thread
theohh0 marked this conversation as resolved.
Resolved
import { KeycloakDataTable } from "@keycloak/keycloak-ui-shared";
import {
KeycloakDataTable,
ListEmptyState,
} from "@keycloak/keycloak-ui-shared";
import { Button, Modal, ModalVariant } from "@patternfly/react-core";
import { TableText } from "@patternfly/react-table";
import { differenceBy } from "lodash-es";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useAdminClient } from "../admin-client";

type OrganizationModalProps = {
isJoin?: boolean;
mode?: "join" | "send" | "add";
existingOrgs: OrganizationRepresentation[];
onAdd: (orgs: OrganizationRepresentation[]) => Promise<void>;
onClose: () => void;
isRadio?: boolean;
};

const TITLES = {
join: "joinOrganization",
send: "sendInvitation",
add: "selectOrganization",
};

export const OrganizationModal = ({
isJoin = true,
mode = "join",
existingOrgs,
onAdd,
onClose,
isRadio = false,
}: OrganizationModalProps) => {
const { adminClient } = useAdminClient();
const { t } = useTranslation();
Expand All @@ -30,30 +40,36 @@ export const OrganizationModal = ({
const params = {
first,
search,
max: max! + existingOrgs.length,
max: max!,
};

const orgs = await adminClient.organizations.find(params);
return differenceBy(orgs, existingOrgs, "id");

return orgs;
Comment thread
theohh0 marked this conversation as resolved.
Resolved
Comment thread
theohh0 marked this conversation as resolved.
Resolved
};
Comment thread
theohh0 marked this conversation as resolved.
Resolved

const isRowDisabled = (row?: OrganizationRepresentation) => {
return existingOrgs.some((org) => org.id === row?.id);
};

return (
<Modal
variant={ModalVariant.small}
title={isJoin ? t("joinOrganization") : t("sendInvitation")}
title={t(TITLES[mode])}
Comment thread
theohh0 marked this conversation as resolved.
Resolved
isOpen
>
actions={[
<Button
data-testid="join"
data-testid={mode === "add" ? "add" : "join"}
Comment thread
theohh0 marked this conversation as resolved.
Resolved
key="confirm"
variant="primary"
() => {
await onAdd(selectedRows);
onClose();
}}
Comment thread
theohh0 marked this conversation as resolved.
Resolved
isDisabled={selectedRows.length === 0}
>
{isJoin ? t("join") : t("send")}
{t(mode)}
Comment thread
theohh0 marked this conversation as resolved.
Resolved
Comment thread
theohh0 marked this conversation as resolved.
Resolved
</Button>,
<Button
data-testid="cancel"
Expand All @@ -72,6 +88,7 @@ export const OrganizationModal = ({
searchPlaceholderKey="searchOrganization"
canSelectAll
=> setSelectedRows([...rows])}
isRowDisabled={isRowDisabled}
columns={[
{
name: "name",
Expand All @@ -84,6 +101,16 @@ export const OrganizationModal = ({
),
},
]}
isRadio={isRadio}
emptyState={
<ListEmptyState
hasIcon={false}
message={t("emptyOrganizations")}
instructions={t("emptyOrganizationsInstructions")}
/> /* This component doesn't ever get rendered on the user join org screen if there are
no organizations so this doesn't actually change any functionality there.
Empty message when searching already handled within KeycloakDataTable. */
}
Comment thread
theohh0 marked this conversation as resolved.
Resolved
/>
</Modal>
);
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
theohh0 marked this conversation as resolved.
Resolved
default:
return undefined;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import {
FormErrorText,
HelpItem,
useFetch,
} from "@keycloak/keycloak-ui-shared";

import { useTranslation } from "react-i18next";
import { Button, FormGroup } from "@patternfly/react-core";
import OrganizationRepresentation from "@keycloak/keycloak-admin-client/lib/defs/organizationRepresentation";
import { useState } from "react";
import { Controller, useFormContext } from "react-hook-form";
import { Table, Tbody, Td, Th, Thead, Tr } from "@patternfly/react-table";
import { MinusCircleIcon } from "@patternfly/react-icons";
import { ComponentProps } from "../../components/dynamic/components";
import { useAdminClient } from "../../admin-client";
import { OrganizationModal } from "../../organizations/OrganizationModal";

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

const convertOrganizations = (
organizations: OrganizationRepresentation[],
): string[] => organizations.map(({ id }) => id!);

export const OrganizationSelect = ({
Comment thread
theohh0 marked this conversation as resolved.
Resolved
label,
helpText,
defaultValue,
isDisabled = false,
isRequired,
name,
variant = "typeaheadMulti",
isRadio = false,
}: OrganizationSelectProps) => {
const { adminClient } = useAdminClient();
const { t } = useTranslation();
const {
control,
setValue,
getValues,
formState: { errors },
} = useFormContext();

const values: string[] = getValues(name!) || [];
Comment thread
theohh0 marked this conversation as resolved.
Resolved
Comment thread
theohh0 marked this conversation as resolved.
Resolved
Comment thread
theohh0 marked this conversation as resolved.
Resolved
const [open, setOpen] = useState(false);
const [organizations, setOrganizations] = useState<
OrganizationRepresentation[]
>([]);

useFetch(
() => {
if (values.length > 0) {
return Promise.all(
(values as string[]).map((id) =>
adminClient.organizations.findOne({ id }),
),
);
}
return Promise.resolve([]);
},
(organizations) => {
setOrganizations(
organizations.filter((o) => o) as OrganizationRepresentation[],
);
},
[],
);
Comment thread
theohh0 marked this conversation as resolved.
Resolved
Comment thread
theohh0 marked this conversation as resolved.
Resolved
Comment thread
theohh0 marked this conversation as resolved.
Resolved
Comment thread
theohh0 marked this conversation as resolved.
Resolved
Comment thread
theohh0 marked this conversation as resolved.
Resolved
Comment thread
theohh0 marked this conversation as resolved.
Resolved

const selectOne = variant === "typeahead";

return (
<FormGroup
label={t(label!)}
labelIcon={
<HelpItem helpText={t(helpText!)} fieldLabelId="organizations" />
}
fieldId="organizations"
Comment thread
theohh0 marked this conversation as resolved.
Resolved
Comment thread
theohh0 marked this conversation as resolved.
Resolved
isRequired={isRequired}
>
<Controller
name={name!}
control={control}
defaultValue={defaultValue}
rules={{
validate: (value?: string[]) =>
!isRequired || (value && value.length > 0),
}}
render={({ field }) => (
<>
{open && (
<OrganizationModal
Comment thread
ssilvert marked this conversation as resolved.
Resolved
mode="add"
existingOrgs={organizations}
=> setOpen(false)}
(orgs) => {
// not actually async but onAdd is called assuming it is
if (selectOne) {
Comment thread
theohh0 marked this conversation as resolved.
Resolved
field.onChange(convertOrganizations(orgs));
setOrganizations(orgs);
} else {
field.onChange([
...(field.value || []),
...convertOrganizations(orgs),
]);
setOrganizations([...organizations, ...orgs]);
}
setOpen(false);
}}
isRadio={isRadio}
/>
)}
<Button
data-testid="select-organization-button"
isDisabled={isDisabled}
variant="secondary"
=> {
setOpen(true);
}}
>
{t("addOrganizations")}
</Button>
</>
)}
/>
{organizations.length > 0 && (
<Table variant="compact">
<Thead>
<Tr>
<Th>{t("organizations")}</Th>
<Th aria-hidden="true" />
</Tr>
</Thead>
<Tbody>
{organizations.map((organization) => (
<Tr key={organization.id}>
<Td>{organization.name}</Td>
<Td>
<Button
variant="link"
className="keycloak__client-authorization__policy-row-remove"
icon={<MinusCircleIcon />}
=> {
setValue(name!, [
Comment thread
ssilvert marked this conversation as resolved.
Resolved
...convertOrganizations(
organizations.filter(
({ id }) => id !== organization.id,
),
),
]);
setOrganizations([
...organizations.filter(
({ id }) => id !== organization.id,
),
]);
}}
/>
Comment thread
theohh0 marked this conversation as resolved.
Resolved
Comment thread
theohh0 marked this conversation as resolved.
Resolved
Comment thread
theohh0 marked this conversation as resolved.
Resolved
</Td>
</Tr>
))}
</Tbody>
</Table>
)}
{errors[name!] && <FormErrorText message={t("requiredOrganizations")} />}
</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
theohh0 marked this conversation as resolved.
Resolved
} as const;

export const isValidComponentType = (value: string) => value in COMPONENTS;
Expand Down
2 changes: 1 addition & 1 deletion js/apps/admin-ui/src/user/Organizations.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ export const Organizations = ({ user }: OrganizationProps) => {
)}
{openOrganizationPicker && (
<OrganizationModal
isJoin={shouldJoin}
mode={shouldJoin ? "join" : "send"}
existingOrgs={userOrgs}
=> setOpenOrganizationPicker(false)}
(orgs) => {
Expand Down
Loading
Loading
0