8000 Add admin REST endpoints to list a user's identity provider links by sguilhen · Pull Request #54617 · 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
@@ -0,0 +1,52 @@
/*
* Copyright 2026 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.keycloak.representations.idm;

/**
* Represents an identity provider either already linked to a user, or available for the user to link to.
*/
public class FederatedIdentityLinkRepr 8000 esentation extends FederatedIdentityRepresentation {

protected String providerId;
protected String displayName;
protected Boolean social;

public String getProviderId() {
return providerId;
}

public void setProviderId(String providerId) {
this.providerId = providerId;
}

public String getDisplayName() {
return displayName;
}

public void setDisplayName(String displayName) {
this.displayName = displayName;
}

public Boolean isSocial() {
return social;
}

public void setSocial(Boolean social) {
this.social = social;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
import jakarta.ws.rs.core.Response;

import org.keycloak.representations.idm.CredentialRepresentation;
import org.keycloak.representations.idm.FederatedIdentityLinkRepresentation;
import org.keycloak.representations.idm.FederatedIdentityRepresentation;
import org.keycloak.representations.idm.GroupRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
Expand Down Expand Up @@ -314,6 +315,11 @@ void executeActionsEmail(@QueryParam("client_id") String clientId,
@DELETE
void removeFederatedIdentity(final @PathParam("provider") String provider);

@GET
@Path("federated-identity-links")
List<FederatedIdentityLinkRepresentation> getFederatedIdentityLinks(@QueryParam("linked") Boolean linked,
@QueryParam("search") String search, @QueryParam("first") Integer firstResult, @QueryParam("max") Integer maxResults);

@Path("role-mappings")
RoleMappingResource roles();

Expand Down
211 changes: 68 additions & 143 deletions js/apps/admin-ui/src/user/UserIdentityProviderLinks.tsx
Original file line number Diff line number Diff li 8000 ne change
@@ -1,6 +1,4 @@
import type FederatedIdentityRepresentation from "@keycloak/keycloak-admin-client/lib/defs/federatedIdentityRepresentation";
import type IdentityProviderRepresentation from "@keycloak/keycloak-admin-client/lib/defs/identityProviderRepresentation";
import type { IdentityProvidersQuery } from "@keycloak/keycloak-admin-client/lib/resources/identityProviders";
import type FederatedIdentityLinkRepresentation from "@keycloak/keycloak-admin-client/lib/defs/federatedIdentityLinkRepresentation";
import {
AlertVariant,
Button,
Expand All @@ -9,7 +7,6 @@ import {
PageSection,
Text,
TextContent,
Spinner,
} from "@patternfly/react-core";
import { cellWidth } from "@patternfly/react-table";
import { capitalize } from "lodash-es";
Expand All @@ -18,11 +15,10 @@ import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { FormPanel } from "@keycloak/keycloak-ui-shared";
import { useAdminClient } from "../admin-client";
import { useAlerts, useFetch } from "@keycloak/keycloak-ui-shared";
import { useAlerts } from "@keycloak/keycloak-ui-shared";
import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog";
import { KeycloakDataTable } from "@keycloak/keycloak-ui-shared";
import { useRealm } from "../context/realm-context/RealmContext";
import { useServerInfo } from "../context/server-info/ServerInfoProvider";
import { toIdentityProvider } from "../identity-providers/routes/IdentityProvider";
import { emptyFormatter, upperCaseFormatter } from "../util";
import { UserIdpModal } from "./UserIdPModal";
Expand All @@ -37,8 +33,6 @@ export const UserIdentityProviderLinks = ({
}: UserIdentityProviderLinksProps) => {
const { adminClient } = useAdminClient();
const [key, setKey] = useState(0);
const [linkedNames, setLinkedNames] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [federatedId, setFederatedId] = useState("");
const [isLinkIdPModalOpen, setIsLinkIdPModalOpen] = useState(false);
const { realm } = useRealm();
Expand All @@ -51,65 +45,25 @@ export const UserIdentityProviderLinks = ({
"view-identity-providers",
);

useFetch(
() => adminClient.users.listFederatedIdentities({ id: userId }),
(linkedIdentities) => {
setLinkedNames(
linkedIdentities.map((identity) => identity.identityProvider!),
);
setIsLoading(false);
},
[userId, key],
);

const refresh = () => {
setKey(new Date().getTime());
setIsLoading(true);
};

type WithProviderId = FederatedIdentityRepresentation & {
providerId: string;
};

const identityProviders = useServerInfo().identityProviders;

const getFederatedIdentities = async () => {
const allFedIds = (await adminClient.users.listFederatedIdentities({
id: userId,
})) as WithProviderId[];

if (canQueryIDPDetails) {
const allProviders = await adminClient.identityProviders.find();
for (const element of allFedIds) {
element.providerId = allProviders.find(
(item) => item.alias === element.identityProvider,
)?.providerId!;
}
}

return allFedIds;
};

const linkedIdPsLoader = async () => {
return getFederatedIdentities();
};
const linkedIdPsLoader = () =>
adminClient.users.listFederatedIdentityLinks({ id: userId, linked: true });

const availableIdPsLoader = async (
first?: number,
max?: number,
search?: string,
) => {
const params: IdentityProvidersQuery = {
first: first!,
max: max!,
realmOnly: false,
capability: "USER_LINKING",
};
if (search) {
params.search = search;
}
return await adminClient.identityProviders.find(params);
};
) =>
adminClient.users.listFederatedIdentityLinks({
id: userId,
linked: false,
first,
max,
search,
});

const [toggleUnlinkDialog, UnlinkConfirm] = useConfirmDialog({
titleKey: t("unlinkAccountTitle", {
Expand All @@ -134,15 +88,15 @@ export const UserIdentityProviderLinks = ({
},
});

const idpLinkRenderer = (idp: WithProviderId) => {
const idpLinkRenderer = (idp: FederatedIdentityLinkRepresentation) => {
if (!canQueryIDPDetails)
return <span>{capitalize(idp.identityProvider)}</span>;

return (
<Link
to={toIdentityProvider({
realm,
providerId: idp.providerId,
providerId: idp.providerId!,
alias: idp.identityProvider!,
tab: "settings",
})}
Expand All @@ -152,33 +106,13 @@ export const UserIdentityProviderLinks = ({
);
};

const badgeRenderer1 = (idp: FederatedIdentityRepresentation) => {
const groupName = identityProviders?.find(
(provider) => provider["id"] === idp.identityProvider,
)?.groupName!;
return (
<Label color={groupName === "Social" ? "blue" : "orange"}>
{groupName === "Social" ? t("idpType.social") : t("idpType.custom")}
</Label>
);
};

const badgeRenderer2 = (idp: IdentityProviderRepresentation) => {
const groupName = identityProviders?.find(
(provider) => provider["id"] === idp.providerId,
)?.groupName!;
return (
<Label color={groupName === "User-defined" ? "orange" : "blue"}>
{groupName === "User-defined"
? "Custom"
: groupName! === "Social"
? t("idpType.social")
: groupName!}
</Label>
);
};
const badgeRenderer = (idp: FederatedIdentityLinkRepresentation) => (
<Label color={idp.social ? "blue" : "orange"}>
{idp.social ? t("idpType.social") : t("idpType.custom")}
</Label>
);

const unlinkRenderer = (fedIdentity: FederatedIdentityRepresentation) => {
const unlinkRenderer = (fedIdentity: FederatedIdentityLinkRepresentation) => {
if (!hasAccess("manage-users")) return <span />;

return (
Expand All @@ -194,21 +128,17 @@ export const UserIdentityProviderLinks = ({
);
};

const linkRenderer = (idp: IdentityProviderRepresentation) => {
if (linkedNames.includes(idp.alias!)) return <span />;

return (
<Button
variant="link"
=> {
setFederatedId(idp.alias!);
setIsLinkIdPModalOpen(true);
}}
>
{t("linkAccount")}
</Button>
);
};
const linkRenderer = (idp: FederatedIdentityLinkRepresentation) => (
<Button
variant="link"
=> {
setFederatedId(idp.identityProvider!);
setIsLinkIdPModalOpen(true);
}}
>
{t("linkAccount")}
</Button>
);

const linkedIdpColumns = () => {
const columns = [
Expand Down Expand Up @@ -237,13 +167,12 @@ export const UserIdentityProviderLinks = ({
},
];

if (canQueryIDPDetails)
columns.splice(1, 0, {
name: "type",
displayKey: "type",
cellRenderer: badgeRenderer1,
transforms: [cellWidth(10)],
});
columns.splice(1, 0, {
name: "type",
displayKey: "type",
cellRenderer: badgeRenderer,
transforms: [cellWidth(10)],
});

return columns;
};
Expand Down Expand Up @@ -287,41 +216,37 @@ export const UserIdentityProviderLinks = ({
{t("availableIdPsText")}
</Text>
</TextContent>
{isLoading ? (
<Spinner />
) : (
<KeycloakDataTable
loader={availableIdPsLoader}
key={key}
isPaginated
searchPlaceholderKey="searchForProvider"
ariaLabelKey="LinkedIdPs"
className="kc-linked-IdPs-table"
columns={[
{
name: "alias",
displayKey: "name",
cellFormatters: [emptyFormatter(), upperCaseFormatter()],
transforms: [cellWidth(20)],
},
{
name: "type",
displayKey: "type",
cellRenderer: badgeRenderer2,
transforms: [cellWidth(60)],
},
{
name: "",
cellRenderer: linkRenderer,
},
]}
emptyState={
<TextContent className="kc-no-providers-text">
<Text>{t("noAvailableIdentityProviders")}</Text>
</TextContent>
}
/>
)}
<KeycloakDataTable
loader={availableIdPsLoader}
key={key}
isPaginated
searchPlaceholderKey="searchForProvider"
ariaLabelKey="LinkedIdPs"
className="kc-linked-IdPs-table"
columns={[
{
name: "identityProvider",
displayKey: "name",
cellFormatters: [emptyFormatter(), upperCaseFormatter()],
transforms: [cellWidth(20)],
},
{
name: "type",
displayKey: "type",
cellRenderer: badgeRenderer,
transforms: [cellWidth(60)],
},
{
name: "",
cellRenderer: linkRenderer,
},
]}
emptyState={
<TextContent className="kc-no-providers-text">
<Text>{t("noAvailableIdentityProviders")}</Text>
</TextContent>
}
/>
</FormPanel>
)}
</PageSection>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type FederatedIdentityRepresentation from "./federatedIdentityRepresentation.js";

/**
* Represents an identity provider either already linked to a user, or available for the user to link to.
*/
export default interface FederatedIdentityLinkRepresentation
extends FederatedIdentityRepresentation {
providerId?: string;
displayName?: string;
social?: boolean;
}
Loading
Loading
0