mirror of
https://github.com/keycloak/keycloak.git
synced 2025-12-16 20:15:46 -06:00
Backport to expose membership type
Signed-off-by: Agnieszka Gancarczyk <agagancarczyk@gmail.com>
This commit is contained in:
committed by
Pedro Igor
parent
3400602ee6
commit
f0243a8c0b
@@ -27,4 +27,6 @@ public enum MembershipType {
|
||||
* Indicates that member cannot exist without group/organization.
|
||||
*/
|
||||
MANAGED;
|
||||
|
||||
public static final String NAME = "membershipType";
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import org.keycloak.representations.idm.MemberRepresentation;
|
||||
import org.keycloak.representations.idm.MembershipType;
|
||||
import org.keycloak.representations.idm.OrganizationRepresentation;
|
||||
|
||||
public interface OrganizationMembersResource {
|
||||
@@ -83,6 +84,28 @@ public interface OrganizationMembersResource {
|
||||
@QueryParam("max") Integer max
|
||||
);
|
||||
|
||||
/**
|
||||
* Return all organization members that match the specified filters.
|
||||
*
|
||||
* @param search a {@code String} representing either a member's username, e-mail, first name, or last name.
|
||||
* @param exact if {@code true}, the members will be searched using exact match for the {@code search} param - i.e.
|
||||
* at least one of the username main attributes must match exactly the {@code search} param. If false,
|
||||
* the method returns all members with at least one main attribute partially matching the {@code search} param.
|
||||
* @param membershipType The {@link org.keycloak.representations.idm.MembershipType}.
|
||||
* @param first index of the first element (pagination offset).
|
||||
* @param max the maximum number of results.
|
||||
* @return a list containing the matched organization members.
|
||||
*/
|
||||
@GET
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
List<MemberRepresentation> search(
|
||||
@QueryParam("search") String search,
|
||||
@QueryParam("exact") Boolean exact,
|
||||
@QueryParam("membershipType") MembershipType membershipType,
|
||||
@QueryParam("first") Integer first,
|
||||
@QueryParam("max") Integer max
|
||||
);
|
||||
|
||||
@Path("{id}")
|
||||
OrganizationMemberResource member(@PathParam("id") String id);
|
||||
|
||||
@@ -111,4 +134,4 @@ public interface OrganizationMembersResource {
|
||||
@GET
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
List<OrganizationRepresentation> getOrganizations(@PathParam("id") String id);
|
||||
}
|
||||
}
|
||||
@@ -3268,4 +3268,21 @@ groupDuplicated=Group duplicated
|
||||
duplicateAGroup=Duplicate group
|
||||
couldNotFetchClientRoleMappings=Could not fetch client role mappings\: {{error}}
|
||||
duplicateGroupWarning=Duplication of groups with a large number of subgroups is not supported. Please ensure that the group you are duplicating does not have a large number of subgroups.
|
||||
errorSavingTranslations=Error saving translations\: '{{error}}'
|
||||
errorSavingTranslations=Error saving translations\: '{{error}}'
|
||||
clearCachesTitle=Clear Caches
|
||||
realmCache=Realm Cache
|
||||
userCache=User Cache
|
||||
keysCache=Keys Cache
|
||||
clearButtonTitle=Clear
|
||||
clearRealmCacheHelp=This will clear entries for all realms.
|
||||
clearUserCacheHelp=This will clear entries for all realms.
|
||||
clearKeysCacheHelp=Clears all entries from the cache of external public keys. These are keys of external clients or identity providers. This will clear all entries for all realms.
|
||||
clearCacheSuccess=Cache cleared successfully
|
||||
clearCacheError=Could not clear cache\: {{error}}
|
||||
expandRow=Expand row
|
||||
membershipType=Membership type
|
||||
managedMembership=Managed membership
|
||||
filterByMembershipType=Filter by Membership Type
|
||||
organizationsMembersListError=Could not fetch organization members\: {{error}}
|
||||
MANAGED=Managed
|
||||
UNMANAGED=Unmanaged
|
||||
@@ -0,0 +1,84 @@
|
||||
import {
|
||||
Badge,
|
||||
MenuToggle,
|
||||
Select,
|
||||
SelectList,
|
||||
SelectOption,
|
||||
} from "@patternfly/react-core";
|
||||
|
||||
type CheckboxFilterOptions = {
|
||||
value: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
type CheckboxFilterComponentProps = {
|
||||
filterPlaceholderText: string;
|
||||
isOpen: boolean;
|
||||
options: CheckboxFilterOptions[];
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
onToggleClick: () => void;
|
||||
onSelect: (
|
||||
event: React.MouseEvent<HTMLButtonElement>,
|
||||
selection: string,
|
||||
) => void;
|
||||
selectedItems: string[];
|
||||
width?: string;
|
||||
};
|
||||
|
||||
export const CheckboxFilterComponent = ({
|
||||
filterPlaceholderText,
|
||||
isOpen,
|
||||
options,
|
||||
onOpenChange,
|
||||
onToggleClick,
|
||||
onSelect,
|
||||
selectedItems,
|
||||
width,
|
||||
}: CheckboxFilterComponentProps) => {
|
||||
const toggle = (toggleRef: React.RefObject<HTMLButtonElement>) => (
|
||||
<MenuToggle
|
||||
ref={toggleRef}
|
||||
onClick={onToggleClick}
|
||||
isExpanded={isOpen}
|
||||
style={{
|
||||
width,
|
||||
}}
|
||||
>
|
||||
{filterPlaceholderText}
|
||||
{selectedItems.length > 0 && (
|
||||
<Badge isRead className="pf-v5-u-m-xs">
|
||||
{selectedItems.length}
|
||||
</Badge>
|
||||
)}
|
||||
</MenuToggle>
|
||||
);
|
||||
|
||||
return (
|
||||
<Select
|
||||
role="menu"
|
||||
id="checkbox-select"
|
||||
isOpen={isOpen}
|
||||
selected={selectedItems}
|
||||
onSelect={(event, value) => {
|
||||
onSelect(event as React.MouseEvent<HTMLButtonElement>, value as string);
|
||||
}}
|
||||
onOpenChange={onOpenChange}
|
||||
toggle={toggle}
|
||||
data-testid="checkbox-filter-select"
|
||||
>
|
||||
<SelectList>
|
||||
{options.map((option) => (
|
||||
<SelectOption
|
||||
key={option.value}
|
||||
hasCheckbox
|
||||
value={option.value}
|
||||
isSelected={selectedItems.includes(option.value)}
|
||||
data-testid={`checkbox-filter-option-${option.value}`}
|
||||
>
|
||||
{option.label}
|
||||
</SelectOption>
|
||||
))}
|
||||
</SelectList>
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
import {
|
||||
Button,
|
||||
TextInputGroup,
|
||||
TextInputGroupMain,
|
||||
TextInputGroupUtilities,
|
||||
} from "@patternfly/react-core";
|
||||
import { ArrowRightIcon, SearchIcon, TimesIcon } from "@patternfly/react-icons";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type SearchInputComponentProps = {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onSearch: (value: string) => void;
|
||||
onClear: () => void;
|
||||
placeholder?: string;
|
||||
"aria-label"?: string;
|
||||
};
|
||||
|
||||
export const SearchInputComponent = ({
|
||||
value,
|
||||
onChange,
|
||||
onSearch,
|
||||
onClear,
|
||||
placeholder,
|
||||
"aria-label": ariaLabel,
|
||||
}: SearchInputComponentProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<TextInputGroup>
|
||||
<TextInputGroupMain
|
||||
icon={<SearchIcon />}
|
||||
value={value}
|
||||
onChange={(event: React.FormEvent<HTMLInputElement>) =>
|
||||
onChange(event.currentTarget.value)
|
||||
}
|
||||
placeholder={placeholder}
|
||||
aria-label={ariaLabel}
|
||||
data-testid="search-input"
|
||||
/>
|
||||
<TextInputGroupUtilities style={{ marginInline: "0px" }}>
|
||||
{value && (
|
||||
<Button
|
||||
variant="plain"
|
||||
onClick={onClear}
|
||||
aria-label={t("clear")}
|
||||
data-testid="clear-search"
|
||||
icon={<TimesIcon />}
|
||||
/>
|
||||
)}
|
||||
</TextInputGroupUtilities>
|
||||
</TextInputGroup>
|
||||
<Button
|
||||
icon={<ArrowRightIcon />}
|
||||
variant="control"
|
||||
style={{ marginLeft: "0.1rem" }}
|
||||
onClick={() => onSearch(value)}
|
||||
aria-label={t("search")}
|
||||
data-testid="search"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
DropdownItem,
|
||||
DropdownList,
|
||||
MenuToggle,
|
||||
PageSection,
|
||||
ToolbarItem,
|
||||
} from "@patternfly/react-core";
|
||||
import { useState } from "react";
|
||||
@@ -22,6 +21,13 @@ import { useParams } from "../utils/useParams";
|
||||
import useToggle from "../utils/useToggle";
|
||||
import { InviteMemberModal } from "./InviteMemberModal";
|
||||
import { EditOrganizationParams } from "./routes/EditOrganization";
|
||||
import { CheckboxFilterComponent } from "../components/dynamic/CheckboxFilterComponent";
|
||||
import { SearchInputComponent } from "../components/dynamic/SearchInputComponent";
|
||||
import { translationFormatter } from "../utils/translationFormatter";
|
||||
|
||||
type MembershipTypeRepresentation = UserRepresentation & {
|
||||
membershipType?: string;
|
||||
};
|
||||
|
||||
const UserDetailLink = (user: any) => {
|
||||
const { realm } = useRealm();
|
||||
@@ -37,19 +43,79 @@ export const Members = () => {
|
||||
const { adminClient } = useAdminClient();
|
||||
const { id: orgId } = useParams<EditOrganizationParams>();
|
||||
const { addAlert, addError } = useAlerts();
|
||||
|
||||
const [key, setKey] = useState(0);
|
||||
const refresh = () => setKey(key + 1);
|
||||
|
||||
const [open, toggle] = useToggle();
|
||||
const [openAddMembers, toggleAddMembers] = useToggle();
|
||||
const [openInviteMembers, toggleInviteMembers] = useToggle();
|
||||
const [selectedMembers, setSelectedMembers] = useState<UserRepresentation[]>(
|
||||
[],
|
||||
);
|
||||
const [searchText, setSearchText] = useState<string>("");
|
||||
const [searchTriggerText, setSearchTriggerText] = useState<string>("");
|
||||
const [filteredMembershipTypes, setFilteredMembershipTypes] = useState<
|
||||
string[]
|
||||
>([]);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const loader = (first?: number, max?: number, search?: string) =>
|
||||
adminClient.organizations.listMembers({ orgId, first, max, search });
|
||||
const membershipOptions = [
|
||||
{ value: "Managed", label: "Managed" },
|
||||
{ value: "Unmanaged", label: "Unmanaged" },
|
||||
];
|
||||
|
||||
const onToggleClick = () => {
|
||||
setIsOpen(!isOpen);
|
||||
};
|
||||
|
||||
const onSelect = (_event: any, value: string) => {
|
||||
if (filteredMembershipTypes.includes(value)) {
|
||||
setFilteredMembershipTypes(
|
||||
filteredMembershipTypes.filter((item) => item !== value),
|
||||
);
|
||||
} else {
|
||||
setFilteredMembershipTypes([...filteredMembershipTypes, value]);
|
||||
}
|
||||
setIsOpen(false);
|
||||
refresh();
|
||||
};
|
||||
|
||||
const loader = async (first?: number, max?: number) => {
|
||||
try {
|
||||
const membershipType =
|
||||
filteredMembershipTypes.length === 1
|
||||
? filteredMembershipTypes[0]
|
||||
: undefined;
|
||||
|
||||
const memberships: MembershipTypeRepresentation[] =
|
||||
await adminClient.organizations.listMembers({
|
||||
orgId,
|
||||
first,
|
||||
max,
|
||||
search: searchTriggerText,
|
||||
membershipType,
|
||||
});
|
||||
|
||||
return memberships;
|
||||
} catch (error) {
|
||||
addError("organizationsMembersListError", error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (value: string) => {
|
||||
setSearchText(value);
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
setSearchTriggerText(searchText);
|
||||
refresh();
|
||||
};
|
||||
|
||||
const clearInput = () => {
|
||||
setSearchText("");
|
||||
setSearchTriggerText("");
|
||||
refresh();
|
||||
};
|
||||
|
||||
const removeMember = async (selectedMembers: UserRepresentation[]) => {
|
||||
try {
|
||||
@@ -68,8 +134,9 @@ export const Members = () => {
|
||||
|
||||
refresh();
|
||||
};
|
||||
|
||||
return (
|
||||
<PageSection variant="light">
|
||||
<>
|
||||
{openAddMembers && (
|
||||
<MemberModal
|
||||
membersQuery={() => adminClient.organizations.listMembers({ orgId })}
|
||||
@@ -104,11 +171,20 @@ export const Members = () => {
|
||||
loader={loader}
|
||||
isPaginated
|
||||
ariaLabelKey="membersList"
|
||||
searchPlaceholderKey="searchMember"
|
||||
onSelect={(members) => setSelectedMembers([...members])}
|
||||
canSelectAll
|
||||
toolbarItem={
|
||||
<>
|
||||
<ToolbarItem>
|
||||
<SearchInputComponent
|
||||
value={searchText}
|
||||
onChange={handleChange}
|
||||
onSearch={handleSearch}
|
||||
onClear={clearInput}
|
||||
placeholder={t("searchMembers")}
|
||||
aria-label={t("searchMembers")}
|
||||
/>
|
||||
</ToolbarItem>
|
||||
<ToolbarItem>
|
||||
<Dropdown
|
||||
onOpenChange={toggle}
|
||||
@@ -153,6 +229,18 @@ export const Members = () => {
|
||||
{t("removeMember")}
|
||||
</Button>
|
||||
</ToolbarItem>
|
||||
<ToolbarItem>
|
||||
<CheckboxFilterComponent
|
||||
filterPlaceholderText={t("filterByMembershipType")}
|
||||
isOpen={isOpen}
|
||||
options={membershipOptions}
|
||||
onOpenChange={(nextOpen) => setIsOpen(nextOpen)}
|
||||
onToggleClick={onToggleClick}
|
||||
onSelect={onSelect}
|
||||
selectedItems={filteredMembershipTypes}
|
||||
width={"260px"}
|
||||
/>
|
||||
</ToolbarItem>
|
||||
</>
|
||||
}
|
||||
actions={[
|
||||
@@ -177,6 +265,10 @@ export const Members = () => {
|
||||
{
|
||||
name: "lastName",
|
||||
},
|
||||
{
|
||||
name: "membershipType",
|
||||
cellFormatters: [translationFormatter(t)],
|
||||
},
|
||||
]}
|
||||
emptyState={
|
||||
<ListEmptyState
|
||||
@@ -194,7 +286,8 @@ export const Members = () => {
|
||||
]}
|
||||
/>
|
||||
}
|
||||
isSearching={filteredMembershipTypes.length > 0}
|
||||
/>
|
||||
</PageSection>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -398,7 +398,7 @@ export default function EditUser() {
|
||||
title={<TabTitleText>{t("organizations")}</TabTitleText>}
|
||||
{...organizationsTab}
|
||||
>
|
||||
<Organizations />
|
||||
<Organizations user={user} />
|
||||
</Tab>
|
||||
)}
|
||||
<Tab
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import OrganizationRepresentation from "@keycloak/keycloak-admin-client/lib/defs/organizationRepresentation";
|
||||
import UserRepresentation from "@keycloak/keycloak-admin-client/lib/defs/userRepresentation";
|
||||
import {
|
||||
ListEmptyState,
|
||||
OrganizationTable,
|
||||
@@ -16,7 +17,7 @@ import {
|
||||
} from "@patternfly/react-core";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
import { useAdminClient } from "../admin-client";
|
||||
import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog";
|
||||
import { useRealm } from "../context/realm-context/RealmContext";
|
||||
@@ -24,17 +25,29 @@ import { OrganizationModal } from "../organizations/OrganizationModal";
|
||||
import { toEditOrganization } from "../organizations/routes/EditOrganization";
|
||||
import useToggle from "../utils/useToggle";
|
||||
import { UserParams } from "./routes/User";
|
||||
import { toUsers } from "./routes/Users";
|
||||
import { CheckboxFilterComponent } from "../components/dynamic/CheckboxFilterComponent";
|
||||
import { capitalizeFirstLetterFormatter } from "../util";
|
||||
import { SearchInputComponent } from "../components/dynamic/SearchInputComponent";
|
||||
|
||||
export const Organizations = () => {
|
||||
type OrganizationProps = {
|
||||
user: UserRepresentation;
|
||||
};
|
||||
|
||||
type MembershipTypeRepresentation = OrganizationRepresentation &
|
||||
UserRepresentation & {
|
||||
membershipType?: string;
|
||||
};
|
||||
|
||||
export const Organizations = ({ user }: OrganizationProps) => {
|
||||
const { adminClient } = useAdminClient();
|
||||
const { t } = useTranslation();
|
||||
const { id } = useParams<UserParams>();
|
||||
const navigate = useNavigate();
|
||||
const { addAlert, addError } = useAlerts();
|
||||
const { realm } = useRealm();
|
||||
|
||||
const [key, setKey] = useState(0);
|
||||
const refresh = () => setKey(key + 1);
|
||||
|
||||
const [joinToggle, toggle, setJoinToggle] = useToggle();
|
||||
const [shouldJoin, setShouldJoin] = useState(true);
|
||||
const [openOrganizationPicker, setOpenOrganizationPicker] = useState(false);
|
||||
@@ -42,13 +55,98 @@ export const Organizations = () => {
|
||||
const [selectedOrgs, setSelectedOrgs] = useState<
|
||||
OrganizationRepresentation[]
|
||||
>([]);
|
||||
const [searchText, setSearchText] = useState<string>("");
|
||||
const [searchTriggerText, setSearchTriggerText] = useState<string>("");
|
||||
const [filteredMembershipTypes, setFilteredMembershipTypes] = useState<
|
||||
string[]
|
||||
>([]);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const membershipOptions = [
|
||||
{ value: "Managed", label: "Managed" },
|
||||
{ value: "Unmanaged", label: "Unmanaged" },
|
||||
];
|
||||
|
||||
const onToggleClick = () => {
|
||||
setIsOpen(!isOpen);
|
||||
};
|
||||
|
||||
const onSelect = (_event: any, value: string) => {
|
||||
if (filteredMembershipTypes.includes(value)) {
|
||||
setFilteredMembershipTypes(
|
||||
filteredMembershipTypes.filter((item) => item !== value),
|
||||
);
|
||||
} else {
|
||||
setFilteredMembershipTypes([...filteredMembershipTypes, value]);
|
||||
}
|
||||
setIsOpen(false);
|
||||
refresh();
|
||||
};
|
||||
|
||||
useFetch(
|
||||
() => adminClient.organizations.memberOrganizations({ userId: id! }),
|
||||
async () => {
|
||||
const userOrganizations =
|
||||
await adminClient.organizations.memberOrganizations({ userId: id! });
|
||||
|
||||
const userOrganizationsWithMembershipTypes = await Promise.all(
|
||||
userOrganizations.map(async (org) => {
|
||||
const orgId = org.id;
|
||||
const memberships: MembershipTypeRepresentation[] =
|
||||
await adminClient.organizations.listMembers({
|
||||
orgId: orgId!,
|
||||
});
|
||||
|
||||
const userMemberships = memberships.filter(
|
||||
(membership) => membership.username === user.username,
|
||||
);
|
||||
|
||||
const membershipType = userMemberships.map((membership) => {
|
||||
const formattedMembershipType = capitalizeFirstLetterFormatter()(
|
||||
membership.membershipType,
|
||||
);
|
||||
return formattedMembershipType;
|
||||
});
|
||||
|
||||
return { ...org, membershipType };
|
||||
}),
|
||||
);
|
||||
|
||||
let filteredOrgs = userOrganizationsWithMembershipTypes;
|
||||
if (filteredMembershipTypes.length > 0) {
|
||||
filteredOrgs = filteredOrgs.filter((org) =>
|
||||
org.membershipType?.some((type) =>
|
||||
filteredMembershipTypes.includes(type as string),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (searchTriggerText) {
|
||||
filteredOrgs = filteredOrgs.filter((org) =>
|
||||
org.name?.toLowerCase().includes(searchTriggerText.toLowerCase()),
|
||||
);
|
||||
}
|
||||
|
||||
return filteredOrgs;
|
||||
},
|
||||
setUserOrgs,
|
||||
[key],
|
||||
[key, filteredMembershipTypes, searchTriggerText],
|
||||
);
|
||||
|
||||
const handleChange = (value: string) => {
|
||||
setSearchText(value);
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
setSearchTriggerText(searchText);
|
||||
refresh();
|
||||
};
|
||||
|
||||
const clearInput = () => {
|
||||
setSearchText("");
|
||||
setSearchTriggerText("");
|
||||
refresh();
|
||||
};
|
||||
|
||||
const [toggleDeleteDialog, DeleteConfirm] = useConfirmDialog({
|
||||
titleKey: "removeConfirmOrganizationTitle",
|
||||
messageKey: t("organizationRemoveConfirm", { count: selectedOrgs.length }),
|
||||
@@ -65,6 +163,10 @@ export const Organizations = () => {
|
||||
),
|
||||
);
|
||||
addAlert(t("organizationRemovedSuccess"));
|
||||
const user = await adminClient.users.findOne({ id: id! });
|
||||
if (!user) {
|
||||
navigate(toUsers({ realm: realm }));
|
||||
}
|
||||
setSelectedOrgs([]);
|
||||
refresh();
|
||||
} catch (error) {
|
||||
@@ -92,9 +194,7 @@ export const Organizations = () => {
|
||||
userId: id!,
|
||||
})
|
||||
: adminClient.organizations.inviteExistingUser(
|
||||
{
|
||||
orgId: org.id!,
|
||||
},
|
||||
{ orgId: org.id! },
|
||||
form,
|
||||
);
|
||||
}),
|
||||
@@ -132,6 +232,9 @@ export const Organizations = () => {
|
||||
</Link>
|
||||
)}
|
||||
loader={userOrgs}
|
||||
isSearching={
|
||||
searchTriggerText.length > 0 || filteredMembershipTypes.length > 0
|
||||
}
|
||||
onSelect={(orgs) => setSelectedOrgs(orgs)}
|
||||
deleteLabel="remove"
|
||||
onDelete={(org) => {
|
||||
@@ -140,6 +243,16 @@ export const Organizations = () => {
|
||||
}}
|
||||
toolbarItem={
|
||||
<>
|
||||
<ToolbarItem>
|
||||
<SearchInputComponent
|
||||
value={searchText}
|
||||
placeholder={t("searchMembers")}
|
||||
onChange={handleChange}
|
||||
onSearch={handleSearch}
|
||||
onClear={clearInput}
|
||||
aria-label={t("searchMembers")}
|
||||
/>
|
||||
</ToolbarItem>
|
||||
<ToolbarItem>
|
||||
<Dropdown
|
||||
onOpenChange={setJoinToggle}
|
||||
@@ -187,6 +300,18 @@ export const Organizations = () => {
|
||||
{t("remove")}
|
||||
</Button>
|
||||
</ToolbarItem>
|
||||
<ToolbarItem>
|
||||
<CheckboxFilterComponent
|
||||
filterPlaceholderText={t("filterByMembershipType")}
|
||||
isOpen={isOpen}
|
||||
options={membershipOptions}
|
||||
onOpenChange={(nextOpen) => setIsOpen(nextOpen)}
|
||||
onToggleClick={onToggleClick}
|
||||
onSelect={onSelect}
|
||||
selectedItems={filteredMembershipTypes}
|
||||
width={"260px"}
|
||||
/>
|
||||
</ToolbarItem>
|
||||
</>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -156,6 +156,17 @@ export const upperCaseFormatter =
|
||||
return (value ? toUpperCase(value) : undefined) as string;
|
||||
};
|
||||
|
||||
export const capitalizeFirstLetterFormatter =
|
||||
(): IFormatter => (data?: IFormatterValueType) => {
|
||||
const value = data?.toString();
|
||||
|
||||
return (
|
||||
value
|
||||
? value.charAt(0).toUpperCase() + value.slice(1).toLowerCase()
|
||||
: undefined
|
||||
) as string;
|
||||
};
|
||||
|
||||
export const alphaRegexPattern = /[^A-Za-z]/g;
|
||||
|
||||
export const emailRegexPattern =
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface OrganizationQuery extends PaginatedQuery {
|
||||
|
||||
interface MemberQuery extends PaginatedQuery {
|
||||
orgId: string; //Id of the organization to get the members of
|
||||
membershipType?: string;
|
||||
}
|
||||
|
||||
export class Organizations extends Resource<{ realm?: string }> {
|
||||
|
||||
@@ -53,7 +53,7 @@ const Domains = (org: OrganizationRepresentation) => {
|
||||
);
|
||||
};
|
||||
|
||||
type OrganizationTableProps = PropsWithChildren & {
|
||||
export type OrganizationTableProps = PropsWithChildren & {
|
||||
loader:
|
||||
| LoaderFunction<OrganizationRepresentation>
|
||||
| OrganizationRepresentation[];
|
||||
@@ -62,6 +62,7 @@ type OrganizationTableProps = PropsWithChildren & {
|
||||
>;
|
||||
toolbarItem?: ReactNode;
|
||||
isPaginated?: boolean;
|
||||
isSearching?: boolean;
|
||||
onSelect?: (orgs: OrganizationRepresentation[]) => void;
|
||||
onDelete?: (org: OrganizationRepresentation) => void;
|
||||
deleteLabel?: string;
|
||||
@@ -71,6 +72,7 @@ export const OrganizationTable = ({
|
||||
loader,
|
||||
toolbarItem,
|
||||
isPaginated = false,
|
||||
isSearching = false,
|
||||
onSelect,
|
||||
onDelete,
|
||||
deleteLabel = "delete",
|
||||
@@ -83,8 +85,8 @@ export const OrganizationTable = ({
|
||||
<KeycloakDataTable
|
||||
loader={loader}
|
||||
isPaginated={isPaginated}
|
||||
isSearching={isSearching}
|
||||
ariaLabelKey="organizationList"
|
||||
searchPlaceholderKey="searchOrganization"
|
||||
toolbarItem={toolbarItem}
|
||||
onSelect={onSelect}
|
||||
canSelectAll={onSelect !== undefined}
|
||||
@@ -115,6 +117,10 @@ export const OrganizationTable = ({
|
||||
name: "description",
|
||||
displayKey: "description",
|
||||
},
|
||||
{
|
||||
name: "membershipType",
|
||||
displayKey: "membershipType",
|
||||
},
|
||||
]}
|
||||
emptyState={children}
|
||||
/>
|
||||
|
||||
@@ -43,6 +43,10 @@
|
||||
<groupId>org.keycloak</groupId>
|
||||
<artifactId>keycloak-model-storage</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.keycloak</groupId>
|
||||
<artifactId>keycloak-model-jpa</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.keycloak</groupId>
|
||||
<artifactId>keycloak-model-storage-private</artifactId>
|
||||
|
||||
@@ -17,9 +17,10 @@
|
||||
|
||||
package org.keycloak.models.cache.infinispan;
|
||||
|
||||
import static org.keycloak.organization.utils.Organizations.isReadOnlyOrganizationMember;
|
||||
|
||||
import org.jboss.logging.Logger;
|
||||
import org.keycloak.cluster.ClusterProvider;
|
||||
import org.keycloak.common.Profile;
|
||||
import org.keycloak.credential.CredentialInput;
|
||||
import org.keycloak.models.ClientScopeModel;
|
||||
import org.keycloak.models.CredentialValidationOutput;
|
||||
@@ -56,7 +57,6 @@ import org.keycloak.models.cache.infinispan.events.UserUpdatedEvent;
|
||||
import org.keycloak.models.cache.infinispan.stream.InIdentityProviderPredicate;
|
||||
import org.keycloak.models.utils.KeycloakModelUtils;
|
||||
import org.keycloak.models.utils.ReadOnlyUserModelDelegate;
|
||||
import org.keycloak.organization.OrganizationProvider;
|
||||
import org.keycloak.storage.CacheableStorageProviderModel;
|
||||
import org.keycloak.storage.DatastoreProvider;
|
||||
import org.keycloak.storage.StoreManagers;
|
||||
@@ -339,7 +339,7 @@ public class UserCacheSession implements UserCache, OnCreateComponent, OnUpdateC
|
||||
protected UserModel cacheUser(RealmModel realm, UserModel delegate, Long revision) {
|
||||
int notBefore = getDelegate().getNotBeforeOfUser(realm, delegate);
|
||||
|
||||
if (isReadOnlyOrganizationMember(delegate)) {
|
||||
if (isReadOnlyOrganizationMember(session, delegate)) {
|
||||
return new ReadOnlyUserModelDelegate(delegate, false);
|
||||
}
|
||||
|
||||
@@ -967,27 +967,6 @@ public class UserCacheSession implements UserCache, OnCreateComponent, OnUpdateC
|
||||
return List.of();
|
||||
}
|
||||
|
||||
private boolean isReadOnlyOrganizationMember(UserModel delegate) {
|
||||
if (delegate == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!Profile.isFeatureEnabled(Profile.Feature.ORGANIZATION)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
OrganizationProvider organizationProvider = session.getProvider(OrganizationProvider.class);
|
||||
|
||||
if (organizationProvider.count() == 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// check if provider is enabled and user is managed member of a disabled organization OR provider is disabled and user is managed member
|
||||
return organizationProvider.getByMember(delegate)
|
||||
.anyMatch((org) -> (organizationProvider.isEnabled() && org.isManaged(delegate) && !org.isEnabled()) ||
|
||||
(!organizationProvider.isEnabled() && org.isManaged(delegate)));
|
||||
}
|
||||
|
||||
public UserCacheManager getCache() {
|
||||
return cache;
|
||||
}
|
||||
@@ -1004,4 +983,4 @@ public class UserCacheSession implements UserCache, OnCreateComponent, OnUpdateC
|
||||
public boolean isInvalid(String key) {
|
||||
return invalidations.contains(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ package org.keycloak.models.cache.infinispan.organization;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Stream;
|
||||
import org.keycloak.models.IdentityProviderModel;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
@@ -176,7 +177,15 @@ public class InfinispanOrganizationProvider implements OrganizationProvider {
|
||||
|
||||
@Override
|
||||
public Stream<UserModel> getMembersStream(OrganizationModel organization, String search, Boolean exact, Integer first, Integer max) {
|
||||
return getDelegate().getMembersStream(organization, search, exact, first, max);
|
||||
Map<String, String> filters = Optional.ofNullable(search)
|
||||
.map(value -> Map.of(UserModel.SEARCH, value))
|
||||
.orElse(Map.of());
|
||||
return getMembersStream(organization, filters, exact, first, max);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Stream<UserModel> getMembersStream(OrganizationModel organization, Map<String, String> filters, Boolean exact, Integer first, Integer max) {
|
||||
return getDelegate().getMembersStream(organization, filters, exact, first, max);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -402,4 +411,4 @@ public class InfinispanOrganizationProvider implements OrganizationProvider {
|
||||
private boolean isUserCacheKeyInvalid(String cacheKey) {
|
||||
return userCache.isInvalid(cacheKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,13 +18,20 @@
|
||||
package org.keycloak.organization.jpa;
|
||||
|
||||
import static org.keycloak.models.OrganizationModel.ORGANIZATION_DOMAIN_ATTRIBUTE;
|
||||
import static org.keycloak.models.UserModel.EMAIL;
|
||||
import static org.keycloak.models.UserModel.FIRST_NAME;
|
||||
import static org.keycloak.models.UserModel.LAST_NAME;
|
||||
import static org.keycloak.models.UserModel.USERNAME;
|
||||
import static org.keycloak.models.jpa.PaginationUtils.paginateQuery;
|
||||
import static org.keycloak.organization.utils.Organizations.isReadOnlyOrganizationMember;
|
||||
import static org.keycloak.utils.StreamsUtil.closing;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Map.Entry;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
@@ -33,6 +40,7 @@ import jakarta.persistence.NoResultException;
|
||||
import jakarta.persistence.TypedQuery;
|
||||
import jakarta.persistence.criteria.CriteriaBuilder;
|
||||
import jakarta.persistence.criteria.CriteriaQuery;
|
||||
import jakarta.persistence.criteria.From;
|
||||
import jakarta.persistence.criteria.Join;
|
||||
import jakarta.persistence.criteria.Predicate;
|
||||
import jakarta.persistence.criteria.Root;
|
||||
@@ -56,6 +64,7 @@ import org.keycloak.models.jpa.entities.OrganizationEntity;
|
||||
import org.keycloak.models.jpa.entities.UserEntity;
|
||||
import org.keycloak.models.jpa.entities.UserGroupMembershipEntity;
|
||||
import org.keycloak.models.utils.KeycloakModelUtils;
|
||||
import org.keycloak.models.utils.ReadOnlyUserModelDelegate;
|
||||
import org.keycloak.organization.OrganizationProvider;
|
||||
import org.keycloak.representations.idm.MembershipType;
|
||||
import org.keycloak.organization.utils.Organizations;
|
||||
@@ -275,10 +284,72 @@ public class JpaOrganizationProvider implements OrganizationProvider {
|
||||
|
||||
@Override
|
||||
public Stream<UserModel> getMembersStream(OrganizationModel organization, String search, Boolean exact, Integer first, Integer max) {
|
||||
throwExceptionIfObjectIsNull(organization, "Organization");
|
||||
GroupModel group = getOrganizationGroup(organization);
|
||||
return getMembersStream(organization, Map.of(UserModel.SEARCH, search), exact, first, max);
|
||||
}
|
||||
|
||||
return userProvider.getGroupMembersStream(getRealm(), group, search, exact, first, max);
|
||||
@Override
|
||||
public Stream<UserModel> getMembersStream(OrganizationModel organization, Map<String, String> filters, Boolean exact, Integer first, Integer max) {
|
||||
throwExceptionIfObjectIsNull(organization, "Organization");
|
||||
var builder = em.getCriteriaBuilder();
|
||||
var queryBuilder = builder.createQuery(String.class);
|
||||
var groupMembership = queryBuilder.from(UserGroupMembershipEntity.class);
|
||||
|
||||
queryBuilder.select(groupMembership.get("user").get("id"));
|
||||
|
||||
var predicates = new ArrayList<>();
|
||||
var group = getOrganizationGroup(organization);
|
||||
|
||||
predicates.add(builder.equal(groupMembership.get("groupId"), group.getId()));
|
||||
|
||||
From<UserGroupMembershipEntity, UserEntity> userJoin = groupMembership.join("user");
|
||||
|
||||
for (Entry<String, String> filter : Optional.ofNullable(filters).orElse(Map.of()).entrySet()) {
|
||||
switch (filter.getKey()) {
|
||||
case UserModel.SEARCH -> predicates.add(builder
|
||||
.or(getSearchOptionPredicateArray(filter.getValue(), Optional.ofNullable(exact).orElse(false), builder, userJoin)));
|
||||
case MembershipType.NAME -> predicates.add(builder
|
||||
.equal(groupMembership.get(MembershipType.NAME), filter.getValue().toUpperCase()));
|
||||
}
|
||||
}
|
||||
|
||||
queryBuilder.where(predicates.toArray(new Predicate[0]));
|
||||
queryBuilder.orderBy(builder.asc(userJoin.get(USERNAME)));
|
||||
|
||||
return closing(paginateQuery(em.createQuery(queryBuilder), first, max).getResultStream().map(id -> {
|
||||
UserModel user = userProvider.getUserById(getRealm(), id);
|
||||
|
||||
if (isReadOnlyOrganizationMember(session, user)) {
|
||||
return new ReadOnlyUserModelDelegate(user) {
|
||||
@Override
|
||||
public boolean isEnabled() {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return user;
|
||||
}));
|
||||
}
|
||||
|
||||
private Predicate[] getSearchOptionPredicateArray(String value, boolean exact, CriteriaBuilder builder, From<?, UserEntity> from) {
|
||||
value = value.toLowerCase();
|
||||
|
||||
List<Predicate> orPredicates = new ArrayList<>();
|
||||
|
||||
if (exact) {
|
||||
orPredicates.add(builder.equal(from.get(USERNAME), value));
|
||||
orPredicates.add(builder.equal(from.get(EMAIL), value));
|
||||
orPredicates.add(builder.equal(builder.lower(from.get(FIRST_NAME)), value));
|
||||
orPredicates.add(builder.equal(builder.lower(from.get(LAST_NAME)), value));
|
||||
} else {
|
||||
value = "%" + value + "%";
|
||||
orPredicates.add(builder.like(from.get(USERNAME), value));
|
||||
orPredicates.add(builder.like(from.get(EMAIL), value));
|
||||
orPredicates.add(builder.like(builder.lower(from.get(FIRST_NAME)), value));
|
||||
orPredicates.add(builder.like(builder.lower(from.get(LAST_NAME)), value));
|
||||
}
|
||||
|
||||
return orPredicates.toArray(Predicate[]::new);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -531,4 +602,4 @@ public class JpaOrganizationProvider implements OrganizationProvider {
|
||||
}
|
||||
return realm;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -257,7 +257,7 @@ public class ExportUtils {
|
||||
orgProvider.getAllStream().map(model -> {
|
||||
OrganizationRepresentation org = ModelToRepresentation.toRepresentation(model);
|
||||
|
||||
orgProvider.getMembersStream(model, null, null, null, null)
|
||||
orgProvider.getMembersStream(model, (Map<String, String>) null, null, null, null)
|
||||
.forEach(user -> {
|
||||
MemberRepresentation member = new MemberRepresentation();
|
||||
member.setUsername(user.getUsername());
|
||||
|
||||
@@ -17,13 +17,16 @@
|
||||
package org.keycloak.organization;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.keycloak.models.IdentityProviderModel;
|
||||
import org.keycloak.models.ModelDuplicateException;
|
||||
import org.keycloak.models.ModelException;
|
||||
import org.keycloak.models.OrganizationModel;
|
||||
import org.keycloak.models.UserModel;
|
||||
import org.keycloak.provider.Provider;
|
||||
import org.keycloak.representations.idm.MembershipType;
|
||||
|
||||
/**
|
||||
* A {@link Provider} that manages organization and its data within the scope of a realm.
|
||||
@@ -139,9 +142,28 @@ public interface OrganizationProvider extends Provider {
|
||||
*
|
||||
* @param organization the organization
|
||||
* @return Stream of the members. Never returns {@code null}.
|
||||
* @deprecated Use {@link #getMembersStream(OrganizationModel, Map, Boolean, Integer, Integer)} instead.
|
||||
*/
|
||||
@Deprecated(forRemoval = true, since = "26")
|
||||
Stream<UserModel> getMembersStream(OrganizationModel organization, String search, Boolean exact, Integer first, Integer max);
|
||||
|
||||
/**
|
||||
* Returns the members of a given {@link OrganizationModel} filtered according to the specified {@code filters}.
|
||||
*
|
||||
* @param organization the organization
|
||||
* @return Stream of the members. Never returns {@code null}.
|
||||
*/
|
||||
default Stream<UserModel> getMembersStream(OrganizationModel organization, Map<String, String> filters, Boolean exact, Integer first, Integer max) {
|
||||
var result = getMembersStream(organization, Optional.ofNullable(filters).orElse(Map.of()).get(UserModel.SEARCH), exact, first, max);
|
||||
var membershipType = Optional.ofNullable(filters.get(MembershipType.NAME)).map(MembershipType::valueOf).orElse(null);
|
||||
|
||||
if (membershipType != null) {
|
||||
return result.filter(userModel -> MembershipType.MANAGED.equals(membershipType) ? isManagedMember(organization, userModel) : !isManagedMember(organization, userModel));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns number of members in the organization.
|
||||
* @param organization the organization
|
||||
@@ -254,4 +276,4 @@ public interface OrganizationProvider extends Provider {
|
||||
default OrganizationModel getByAlias(String alias) {
|
||||
return getAllStream(Map.of(OrganizationModel.ALIAS, alias), 0, 1).findAny().orElse(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,8 @@
|
||||
|
||||
package org.keycloak.organization.admin.resource;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import jakarta.ws.rs.Consumes;
|
||||
@@ -136,9 +138,20 @@ public class OrganizationMemberResource {
|
||||
@Parameter(description = "A String representing either a member's username, e-mail, first name, or last name.") @QueryParam("search") String search,
|
||||
@Parameter(description = "Boolean which defines whether the param 'search' must match exactly or not") @QueryParam("exact") Boolean exact,
|
||||
@Parameter(description = "The position of the first result to be processed (pagination offset)") @QueryParam("first") @DefaultValue("0") Integer first,
|
||||
@Parameter(description = "The maximum number of results to be returned. Defaults to 10") @QueryParam("max") @DefaultValue("10") Integer max
|
||||
@Parameter(description = "The maximum number of results to be returned. Defaults to 10") @QueryParam("max") @DefaultValue("10") Integer max,
|
||||
@Parameter(description = "The membership type") @QueryParam("membershipType") String membershipType
|
||||
) {
|
||||
return provider.getMembersStream(organization, search, exact, first, max).map(this::toRepresentation);
|
||||
Map<String, String> filters = new HashMap<>();
|
||||
|
||||
if (search != null) {
|
||||
filters.put(UserModel.SEARCH, search);
|
||||
}
|
||||
|
||||
if (membershipType != null) {
|
||||
filters.put(MembershipType.NAME, MembershipType.valueOf(membershipType.toUpperCase()).name());
|
||||
}
|
||||
|
||||
return provider.getMembersStream(organization, filters, exact, first, max).map(this::toRepresentation);
|
||||
}
|
||||
|
||||
@Path("{id}")
|
||||
@@ -234,4 +247,4 @@ public class OrganizationMemberResource {
|
||||
result.setMembershipType(provider.isManagedMember(organization, member) ? MembershipType.MANAGED : MembershipType.UNMANAGED);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -241,4 +241,25 @@ public class Organizations {
|
||||
if (session.getContext().getOrganization() != null) return true;
|
||||
return realm.isRegistrationAllowed();
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean isReadOnlyOrganizationMember(KeycloakSession session, UserModel delegate) {
|
||||
if (delegate == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!Profile.isFeatureEnabled(Profile.Feature.ORGANIZATION)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
var organizationProvider = getProvider(session);
|
||||
|
||||
if (organizationProvider.count() == 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// check if provider is enabled and user is managed member of a disabled organization OR provider is disabled and user is managed member
|
||||
return organizationProvider.getByMember(delegate)
|
||||
.anyMatch((org) -> (organizationProvider.isEnabled() && org.isManaged(delegate) && !org.isEnabled()) ||
|
||||
(!organizationProvider.isEnabled() && org.isManaged(delegate)));
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,7 @@ import org.keycloak.organization.OrganizationProvider;
|
||||
import org.keycloak.representations.idm.AbstractUserRepresentation;
|
||||
import org.keycloak.representations.idm.IdentityProviderRepresentation;
|
||||
import org.keycloak.representations.idm.MemberRepresentation;
|
||||
import org.keycloak.representations.idm.MembershipType;
|
||||
import org.keycloak.representations.idm.OrganizationRepresentation;
|
||||
import org.keycloak.representations.idm.UserRepresentation;
|
||||
import org.keycloak.representations.userprofile.config.UPConfig;
|
||||
@@ -415,6 +416,11 @@ public class OrganizationMemberTest extends AbstractOrganizationTest {
|
||||
assertThat(existing, hasSize(2));
|
||||
assertThat(existing.get(0).getUsername(), is(equalTo("marthaw@neworg.org")));
|
||||
assertThat(existing.get(1).getUsername(), is(equalTo("thejoker@neworg.org")));
|
||||
|
||||
existing = organization.members().search(null, null, MembershipType.MANAGED, -1, -1);
|
||||
assertTrue(existing.isEmpty());
|
||||
existing = organization.members().search(null, null, MembershipType.UNMANAGED, -1, -1);
|
||||
assertThat(existing, hasSize(5));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -570,4 +576,4 @@ public class OrganizationMemberTest extends AbstractOrganizationTest {
|
||||
private UserRepresentation getUserRepFromMemberRep(MemberRepresentation member) {
|
||||
return new UserRepresentation(member);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user