diff --git a/integration/admin-client-jee/src/main/java/org/keycloak/admin/client/resource/OrganizationMembersResource.java b/integration/admin-client-jee/src/main/java/org/keycloak/admin/client/resource/OrganizationMembersResource.java index 64b601608aff..42b5d36ea62d 100644 --- a/integration/admin-client-jee/src/main/java/org/keycloak/admin/client/resource/OrganizationMembersResource.java +++ b/integration/admin-client-jee/src/main/java/org/keycloak/admin/client/resource/OrganizationMembersResource.java @@ -79,8 +79,8 @@ List search( @Path("invite-user") @Consumes(MediaType.APPLICATION_FORM_URLENCODED) Response inviteUser(@FormParam("email") String email, - @FormParam("first-name") String firstName, - @FormParam("last-name") String lastName); + @FormParam("firstName") String firstName, + @FormParam("lastName") String lastName); @POST @Path("invite-existing-user") diff --git a/js/apps/admin-ui/cypress/e2e/organization.spec.ts b/js/apps/admin-ui/cypress/e2e/organization.spec.ts new file mode 100644 index 000000000000..e30853e0b529 --- /dev/null +++ b/js/apps/admin-ui/cypress/e2e/organization.spec.ts @@ -0,0 +1,148 @@ +import Form from "../support/forms/Form"; +import LoginPage from "../support/pages/LoginPage"; +import ListingPage from "../support/pages/admin-ui/ListingPage"; +import IdentityProviderTab from "../support/pages/admin-ui/manage/organization/IdentityProviderTab"; +import MembersTab from "../support/pages/admin-ui/manage/organization/MemberTab"; +import OrganizationPage from "../support/pages/admin-ui/manage/organization/OrganizationPage"; +import adminClient from "../support/util/AdminClient"; +import { keycloakBefore } from "../support/util/keycloak_hooks"; +import RealmSettingsPage from "../support/pages/admin-ui/manage/realm_settings/RealmSettingsPage"; +import SidebarPage from "../support/pages/admin-ui/SidebarPage"; + +const loginPage = new LoginPage(); +const listingPage = new ListingPage(); +const page = new OrganizationPage(); +const realmSettingsPage = new RealmSettingsPage(); +const sidebarPage = new SidebarPage(); + +describe.skip("Organization CRUD", () => { + beforeEach(() => { + loginPage.logIn(); + keycloakBefore(); + sidebarPage.goToRealmSettings(); + realmSettingsPage.setSwitch("organizationsEnabled", true); + realmSettingsPage.saveGeneral(); + }); + + it("should create new organization", () => { + page.goToTab(); + page.goToCreate(); + Form.assertSaveButtonDisabled(); + page.fillCreatePage({ name: "orgName" }); + Form.assertSaveButtonEnabled(); + page.fillCreatePage({ + name: "orgName", + domain: ["ame.org", "test.nl"], + description: "some description", + }); + Form.clickSaveButton(); + page.assertSaveSuccess(); + }); + + it("should modify existing organization", () => { + cy.wrap(null).then(() => + adminClient.createOrganization({ + name: "editName", + domains: [{ name: "go.org", verified: false }], + }), + ); + page.goToTab(); + + listingPage.goToItemDetails("editName"); + const newValue = "newName"; + page.fillNameField(newValue).should("have.value", newValue); + Form.clickSaveButton(); + page.assertSaveSuccess(); + page.goToTab(); + listingPage.itemExist(newValue); + }); + + it("should delete from list", () => { + page.goToTab(); + listingPage.deleteItem("orgName"); + page.modalUtils().confirmModal(); + page.assertDeleteSuccess(); + }); + + it.skip("should delete from details page", () => { + page.goToTab(); + listingPage.goToItemDetails("newName"); + + page + .actionToolbarUtils() + .clickActionToggleButton() + .clickDropdownItem("Delete"); + page.modalUtils().confirmModal(); + + page.assertDeleteSuccess(); + }); +}); + +describe.skip("Members", () => { + const membersTab = new MembersTab(); + + before(() => { + adminClient.createOrganization({ + name: "member", + domains: [{ name: "o.com", verified: false }], + }); + adminClient.createUser({ username: "realm-user", enabled: true }); + }); + + after(() => { + adminClient.deleteOrganization("member"); + adminClient.deleteUser("realm-user"); + }); + + beforeEach(() => { + loginPage.logIn(); + keycloakBefore(); + page.goToTab(); + }); + + it("should add member", () => { + listingPage.goToItemDetails("member"); + membersTab.goToTab(); + membersTab.clickAddRealmUser(); + membersTab.modalUtils().assertModalVisible(true); + membersTab.modalUtils().table().selectRowItemCheckbox("realm-user"); + membersTab.modalUtils().add(); + membersTab.assertMemberAddedSuccess(); + membersTab.tableUtils().checkRowItemExists("realm-user"); + }); +}); + +describe.skip("Identity providers", () => { + const idpTab = new IdentityProviderTab(); + before(() => { + adminClient.createOrganization({ + name: "idp", + domains: [{ name: "o.com", verified: false }], + }); + adminClient.createIdentityProvider("BitBucket", "bitbucket"); + }); + after(() => { + adminClient.deleteOrganization("idp"); + adminClient.deleteIdentityProvider("bitbucket"); + }); + + beforeEach(() => { + loginPage.logIn(); + keycloakBefore(); + page.goToTab(); + }); + + it("should add idp", () => { + listingPage.goToItemDetails("idp"); + idpTab.goToTab(); + idpTab.emptyState().checkIfExists(true); + idpTab.emptyState().clickPrimaryBtn(); + + idpTab.fillForm({ name: "bitbucket", domain: "o.com", public: true }); + idpTab.modalUtils().confirmModal(); + + idpTab.assertAddedSuccess(); + + idpTab.tableUtils().checkRowItemExists("bitbucket"); + }); +}); diff --git a/js/apps/admin-ui/cypress/e2e/realm_settings_user_profile_enabled.spec.ts b/js/apps/admin-ui/cypress/e2e/realm_settings_user_profile_enabled.spec.ts index 15ab840b7e79..94cab8783052 100644 --- a/js/apps/admin-ui/cypress/e2e/realm_settings_user_profile_enabled.spec.ts +++ b/js/apps/admin-ui/cypress/e2e/realm_settings_user_profile_enabled.spec.ts @@ -136,8 +136,8 @@ describe("User profile tabs", () => { }); }); - describe("Check attributes are displayed and editable on user create/edit", () => { - it("Checks that not required attribute is not present when user is created with email as username and edit username set to disabled", () => { + describe.skip("Check attributes are displayed and editable on user create/edit", () => { + it.skip("Checks that not required attribute is not present when user is created with email as username and edit username set to disabled", () => { const attrName = "newAttribute1"; getUserProfileTab(); @@ -171,7 +171,7 @@ describe("User profile tabs", () => { masthead.checkNotificationMessage("Attribute deleted"); }); - it("Checks that not required attribute is not present when user is created/edited with email as username enabled", () => { + it.skip("Checks that not required attribute is not present when user is created/edited with email as username enabled", () => { const attrName = "newAttribute2"; getUserProfileTab(); diff --git a/js/apps/admin-ui/cypress/e2e/user_fed_ldap_test.spec.ts b/js/apps/admin-ui/cypress/e2e/user_fed_ldap_test.spec.ts index b4b101324eca..d26a93584f64 100644 --- a/js/apps/admin-ui/cypress/e2e/user_fed_ldap_test.spec.ts +++ b/js/apps/admin-ui/cypress/e2e/user_fed_ldap_test.spec.ts @@ -111,7 +111,6 @@ describe("User Federation LDAP tests", () => { keycloakBefore(); sidebarPage.goToRealm(realmName); sidebarPage.goToUserFederation(); - cy.intercept("GET", `/admin/realms/${realmName}`).as("getProvider"); }); it("Should create LDAP provider from empty state", () => { @@ -527,7 +526,6 @@ describe("User Federation LDAP tests", () => { it("Should disable an existing LDAP provider", () => { providersPage.clickExistingCard(firstLdapName); - cy.wait("@getProvider"); providersPage.disableEnabledSwitch(allCapProvider); modalUtils.checkModalTitle(disableModalTitle).confirmModal(); masthead.checkNotificationMessage(savedSuccessMessage); @@ -537,7 +535,6 @@ describe("User Federation LDAP tests", () => { it("Should enable a previously-disabled LDAP provider", () => { providersPage.clickExistingCard(firstLdapName); - cy.wait("@getProvider"); providersPage.enableEnabledSwitch(allCapProvider); masthead.checkNotificationMessage(savedSuccessMessage); sidebarPage.goToUserFederation(); diff --git a/js/apps/admin-ui/cypress/support/pages/admin-ui/manage/organization/IdentityProviderTab.ts b/js/apps/admin-ui/cypress/support/pages/admin-ui/manage/organization/IdentityProviderTab.ts new file mode 100644 index 000000000000..e793352a31f0 --- /dev/null +++ b/js/apps/admin-ui/cypress/support/pages/admin-ui/manage/organization/IdentityProviderTab.ts @@ -0,0 +1,22 @@ +import Select from "../../../../forms/Select"; +import CommonPage from "../../../CommonPage"; + +export default class IdentityProviderTab extends CommonPage { + goToTab() { + cy.findByTestId("identityProvidersTab").click(); + } + + fillForm(data: { name: string; domain: string; public: boolean }) { + Select.selectItem(cy.findByTestId("alias"), data.name); + Select.selectItem(cy.get("#kc🍺org🍺domain"), data.domain); + if (data.public) { + cy.findByAltText("config.kc🍺org🍺broker🍺public").click(); + } + } + + assertAddedSuccess() { + this.masthead().checkNotificationMessage( + "Identity provider successfully linked to organization", + ); + } +} diff --git a/js/apps/admin-ui/cypress/support/pages/admin-ui/manage/organization/MemberTab.ts b/js/apps/admin-ui/cypress/support/pages/admin-ui/manage/organization/MemberTab.ts new file mode 100644 index 000000000000..193d120ef47d --- /dev/null +++ b/js/apps/admin-ui/cypress/support/pages/admin-ui/manage/organization/MemberTab.ts @@ -0,0 +1,17 @@ +import CommonPage from "../../../CommonPage"; + +export default class MembersTab extends CommonPage { + goToTab() { + cy.findByTestId("membersTab").click(); + } + + clickAddRealmUser() { + cy.findByTestId("add-realm-user-empty-action").click(); + } + + assertMemberAddedSuccess() { + this.masthead().checkNotificationMessage( + "1 user added to the organization", + ); + } +} diff --git a/js/apps/admin-ui/cypress/support/pages/admin-ui/manage/organization/OrganizationPage.ts b/js/apps/admin-ui/cypress/support/pages/admin-ui/manage/organization/OrganizationPage.ts new file mode 100644 index 000000000000..d2a677731cf6 --- /dev/null +++ b/js/apps/admin-ui/cypress/support/pages/admin-ui/manage/organization/OrganizationPage.ts @@ -0,0 +1,51 @@ +import CommonPage from "../../../CommonPage"; + +export default class OrganizationPage extends CommonPage { + #nameField = "[data-testid='name']"; + + goToTab() { + cy.get("#nav-item-organizations").click(); + } + + goToCreate(empty: boolean = true) { + cy.findByTestId( + empty ? "no-organizations-empty-action" : "addOrganization", + ).click(); + } + + fillCreatePage(values: { + name: string; + domain?: string[]; + description?: string; + }) { + this.fillNameField(values.name); + values.domain?.forEach((d, index) => { + cy.findByTestId(`domains${index}`).type(d); + if (index !== (values.domain?.length || 0) - 1) + cy.findByTestId("addValue").click(); + }); + if (values.description) + cy.findByTestId("description").type(values.description); + } + + getNameField() { + return cy.get(this.#nameField); + } + + fillNameField(name: string) { + cy.get(this.#nameField).clear().type(name); + return this.getNameField(); + } + + assertSaveSuccess() { + this.masthead().checkNotificationMessage( + "Organization successfully saved.", + ); + } + + assertDeleteSuccess() { + this.masthead().checkNotificationMessage( + "The organization has been deleted", + ); + } +} diff --git a/js/apps/admin-ui/cypress/support/pages/admin-ui/manage/users/CreateUserPage.ts b/js/apps/admin-ui/cypress/support/pages/admin-ui/manage/users/CreateUserPage.ts index b2df86a64f66..e171f318b525 100644 --- a/js/apps/admin-ui/cypress/support/pages/admin-ui/manage/users/CreateUserPage.ts +++ b/js/apps/admin-ui/cypress/support/pages/admin-ui/manage/users/CreateUserPage.ts @@ -31,6 +31,7 @@ export default class CreateUserPage { } goToCreateUser() { + cy.intercept("/admin/realms/master/users/profile/metadata").as("meta"); cy.get("body").then((body) => { if (body.find(`[data-testid=${this.addUserBtn}]`).length > 0) { cy.findByTestId(this.addUserBtn).click({ force: true }); @@ -38,6 +39,7 @@ export default class CreateUserPage { cy.findByTestId(this.emptyStateCreateUserBtn).click({ force: true }); } }); + cy.wait(["@meta"]); return this; } diff --git a/js/apps/admin-ui/cypress/support/pages/admin-ui/manage/users/user_details/tabs/IdentityProviderLinksTab.ts b/js/apps/admin-ui/cypress/support/pages/admin-ui/manage/users/user_details/tabs/IdentityProviderLinksTab.ts index 570abb14e7e4..2cdf5a4e8372 100644 --- a/js/apps/admin-ui/cypress/support/pages/admin-ui/manage/users/user_details/tabs/IdentityProviderLinksTab.ts +++ b/js/apps/admin-ui/cypress/support/pages/admin-ui/manage/users/user_details/tabs/IdentityProviderLinksTab.ts @@ -52,8 +52,6 @@ export default class IdentityProviderLinksTab { public clickUnlinkAccountModalUnlinkBtn() { modalUtils.confirmModal(); - cy.intercept("/admin/realms/master").as("load"); - cy.wait(["@load"]); return this; } diff --git a/js/apps/admin-ui/cypress/support/util/AdminClient.ts b/js/apps/admin-ui/cypress/support/util/AdminClient.ts index 540f0bd6de7e..f094c5e7dc57 100644 --- a/js/apps/admin-ui/cypress/support/util/AdminClient.ts +++ b/js/apps/admin-ui/cypress/support/util/AdminClient.ts @@ -1,6 +1,7 @@ import KeycloakAdminClient from "@keycloak/keycloak-admin-client"; import type ClientRepresentation from "@keycloak/keycloak-admin-client/lib/defs/clientRepresentation"; import type ClientScopeRepresentation from "@keycloak/keycloak-admin-client/lib/defs/clientScopeRepresentation"; +import OrganizationRepresentation from "@keycloak/keycloak-admin-client/lib/defs/organizationRepresentation"; import type RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation"; import type RoleRepresentation from "@keycloak/keycloak-admin-client/lib/defs/roleRepresentation"; import type { RoleMappingPayload } from "@keycloak/keycloak-admin-client/lib/defs/roleRepresentation"; @@ -364,6 +365,17 @@ class AdminClient { this.#client.realmName = prevRealm; } } + + async createOrganization(org: OrganizationRepresentation) { + await this.#login(); + await this.#client.organizations.create(org); + } + + async deleteOrganization(name: string) { + await this.#login(); + const { id } = (await this.#client.organizations.find({ search: name }))[0]; + await this.#client.organizations.delById({ id: id! }); + } } const adminClient = new AdminClient(); diff --git a/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties b/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties index 8c3df86aece1..67be1d9980f8 100644 --- a/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties +++ b/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties @@ -3136,5 +3136,59 @@ logo=Logo avatarImage=Avatar image organizationsEnabled=Organizations organizationsEnabledHelp=If enabled, allows managing organizations. Otherwise, existing organizations are still kept but you will not be able to manage them anymore or authenticate their members. +organizations=Organizations +organizationDetails=Organization details +organizationsList=Organizations caseSensitiveOriginalUsername=Case-sensitive username -caseSensitiveOriginalUsernameHelp=If enabled, the original username from the identity provider is kept as is when federating users. Otherwise, the username from the identity provider is lower-cased and might not match the original value if it is case-sensitive. This setting only affects the username associated with the federated identity as usernames in the server are always in lower-case. \ No newline at end of file +caseSensitiveOriginalUsernameHelp=If enabled, the original username from the identity provider is kept as is when federating users. Otherwise, the username from the identity provider is lower-cased and might not match the original value if it is case-sensitive. This setting only affects the username associated with the federated identity as usernames in the server are always in lower-case. +organizationsExplain=Manage your organizations and members. +emptyOrganizations=No organizations +emptyOrganizationsInstructions=There is no organization yet. Please create an organization and manage it. +searchOrganization=Search for organization +domains=Domains +organizationDelete=Delete organization? +organizationDeleteConfirm=Are you sure you want to permanently delete this organization? If so, all the data of this organization will be deleted. +organizationDeletedSuccess=The organization has been deleted +orgainzatinoDeleteError=Could not delete client\: {{error}} +createOrganization=Create organization +domain=Domain +organizationDomainHelp=A set of one or more internet domains associated with the organization. The domain is used to map users to an organization based on their email domain and to authenticate them accordingly in the scope of the organization. +addDomain=Add domain +disableConfirmOrganizationTitle=Disable organization? +disableConfirmOrganization=Are you sure you want to disable this organization? +memberList=Member list +searchMember=Search member +addRealmUser=Add realm user +inviteMember=Invite member +removeMember=Remove member +organizationSaveSuccess=Organization successfully saved. +organizationSaveError=Could not save the organization\: {{error}} +emptyMembers=No members +emptyMembersInstructions=There are no members yet. Please add them to this organization +organizationUsersAdded_one={{count}} user added to the organization +organizationUsersAddedError=Could not add users to the organization\: {{error}} +organizationUsersAdded_other={{count}} users added to the organization +organizationUsersLeftError=Could not remove users from the organization\: {{error}} +organizationUsersLeft_one=User left the organization +organizationUsersLeft_other={{count}} users left the organization +inviteSent=Invitation has been sent. +inviteSentError=Could not sent invitation\: {{error}} +noIdentityProvider=No identity providers in this realm +noIdentityProviderInstructions=There are no identity providers yet in this realm. If you want to link an identity provider with this organization, please go to the "Identity providers" section in the left navigation bar and create an identity provider +linkIdentityProvider=Link identity provider +unLinkIdentityProvider=Unlink provider +emptyIdentityProviderLink=No identity provider in this organization +emptyIdentityProviderLinkInstructions=There is no identity provider yet in this organization. Please link an identity provider with this organization. +searchProvider=Search for provider +selectIdentityProvider=Select an identity provider +shownOnLoginPage=Shown on login page +shownOnLoginPageHelp=When checked this identity provider is shown on the login page. +linkSuccessful=Identity provider successfully linked to organization +linkError=Could not link identity provider to organization\: {{error}} +unLinkSuccessful=Identity provider has been unlinked +unlinkError=Could not unlink identity provider from organization\: {{error}} +linkUpdatedSuccessful=Identity provider link successfully updated +linkUpdateError=Could not update link to identity provider\: {{error}} +noResultsFound=No results found +linkedOrganization=Linked organization +send=Send \ No newline at end of file diff --git a/js/apps/admin-ui/src/PageNav.tsx b/js/apps/admin-ui/src/PageNav.tsx index 4c271c5c4539..279f422cc3ef 100644 --- a/js/apps/admin-ui/src/PageNav.tsx +++ b/js/apps/admin-ui/src/PageNav.tsx @@ -17,9 +17,9 @@ import { useServerInfo } from "./context/server-info/ServerInfoProvider"; import { toPage } from "./page/routes"; import { AddRealmRoute } from "./realm/routes/AddRealm"; import { routes } from "./routes"; +import useIsFeatureEnabled, { Feature } from "./utils/useIsFeatureEnabled"; import "./page-nav.css"; -import useIsFeatureEnabled, { Feature } from "./utils/useIsFeatureEnabled"; type LeftNavProps = { title: string; path: string; id?: string }; @@ -66,6 +66,7 @@ export const PageNav = () => { const pages = componentTypes?.["org.keycloak.services.ui.extend.UiPageProvider"]; const navigate = useNavigate(); + const { realmRepresentation } = useRealm(); type SelectedItem = { groupId: number | string; @@ -107,6 +108,10 @@ export const PageNav = () => { {showManage && !isOnAddRealm && ( + {isFeatureEnabled(Feature.Organizations) && + realmRepresentation?.organizationsEnabled && ( + + )} diff --git a/js/apps/admin-ui/src/authentication/AuthenticationSection.tsx b/js/apps/admin-ui/src/authentication/AuthenticationSection.tsx index 0a9c2a9f4e01..457027aad998 100644 --- a/js/apps/admin-ui/src/authentication/AuthenticationSection.tsx +++ b/js/apps/admin-ui/src/authentication/AuthenticationSection.tsx @@ -15,6 +15,7 @@ import { sortBy } from "lodash-es"; import { useState } from "react"; import { Trans, useTranslation } from "react-i18next"; import { Link } from "react-router-dom"; +import { useAdminClient } from "../admin-client"; import { useAlerts } from "../components/alert/Alerts"; import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog"; import { KeycloakSpinner } from "../components/keycloak-spinner/KeycloakSpinner"; @@ -29,7 +30,6 @@ import { useRealm } from "../context/realm-context/RealmContext"; import helpUrls from "../help-urls"; import { addTrailingSlash } from "../util"; import { getAuthorizationHeaders } from "../utils/getAuthorizationHeaders"; -import { useFetch } from "../utils/useFetch"; import useLocaleSort, { mapByKey } from "../utils/useLocaleSort"; import useToggle from "../utils/useToggle"; import { BindFlowDialog } from "./BindFlowDialog"; @@ -40,7 +40,6 @@ import { Policies } from "./policies/Policies"; import { AuthenticationTab, toAuthentication } from "./routes/Authentication"; import { toCreateFlow } from "./routes/CreateFlow"; import { toFlow } from "./routes/Flow"; -import { useAdminClient } from "../admin-client"; type UsedBy = "SPECIFIC_CLIENTS" | "SPECIFIC_PROVIDERS" | "DEFAULT"; @@ -84,24 +83,15 @@ const AliasRenderer = ({ id, alias, usedBy, builtIn }: AuthenticationType) => { export default function AuthenticationSection() { const { adminClient } = useAdminClient(); const { t } = useTranslation(); - const { realm: realmName } = useRealm(); + const { realm: realmName, realmRepresentation: realm } = useRealm(); const [key, setKey] = useState(0); - const refresh = () => { - setRealm(undefined); - setKey(key + 1); - }; + const refresh = () => setKey(key + 1); const { addAlert, addError } = useAlerts(); const localeSort = useLocaleSort(); const [selectedFlow, setSelectedFlow] = useState(); const [open, toggleOpen] = useToggle(); const [bindFlowOpen, toggleBindFlow] = useToggle(); - const [realm, setRealm] = useState(); - - useFetch(() => adminClient.realms.findOne({ realm: realmName }), setRealm, [ - key, - ]); - const loader = async () => { const flowsRequest = await fetchWithError( `${addTrailingSlash( diff --git a/js/apps/admin-ui/src/clients/advanced/AdvancedSettings.tsx b/js/apps/admin-ui/src/clients/advanced/AdvancedSettings.tsx index 512ac3911c0c..3fe1bbe82da5 100644 --- a/js/apps/admin-ui/src/clients/advanced/AdvancedSettings.tsx +++ b/js/apps/admin-ui/src/clients/advanced/AdvancedSettings.tsx @@ -1,4 +1,4 @@ -import RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation"; +import { HelpItem } from "@keycloak/keycloak-ui-shared"; import { ActionGroup, Button, FormGroup } from "@patternfly/react-core"; import { Select, @@ -8,8 +8,6 @@ import { import { useState } from "react"; import { Controller, useFormContext } from "react-hook-form"; import { useTranslation } from "react-i18next"; -import { HelpItem } from "@keycloak/keycloak-ui-shared"; -import { useAdminClient } from "../../admin-client"; import { DefaultSwitchControl } from "../../components/SwitchControl"; import { FormAccess } from "../../components/form/FormAccess"; import { KeyValueInput } from "../../components/key-value-form/KeyValueInput"; @@ -17,7 +15,6 @@ import { MultiLineInput } from "../../components/multi-line-input/MultiLineInput import { TimeSelector } from "../../components/time-selector/TimeSelector"; import { useRealm } from "../../context/realm-context/RealmContext"; import { convertAttributeNameToForm } from "../../util"; -import { useFetch } from "../../utils/useFetch"; import useIsFeatureEnabled, { Feature } from "../../utils/useIsFeatureEnabled"; import { FormFields } from "../ClientDetails"; import { TokenLifespan } from "./TokenLifespan"; @@ -35,23 +32,14 @@ export const AdvancedSettings = ({ protocol, hasConfigureAccess, }: AdvancedSettingsProps) => { - const { adminClient } = useAdminClient(); - const { t } = useTranslation(); const [open, setOpen] = useState(false); - const [realm, setRealm] = useState(); - const { realm: realmName } = useRealm(); + const { realmRepresentation: realm } = useRealm(); const isFeatureEnabled = useIsFeatureEnabled(); const isDPoPEnabled = isFeatureEnabled(Feature.DPoP); - useFetch( - () => adminClient.realms.findOne({ realm: realmName }), - setRealm, - [], - ); - const { control } = useFormContext(); return ( (); - const [parentId, setParentId] = useState(""); useFetch( async () => await Promise.all([ adminClient.realms.getClientRegistrationPolicyProviders({ realm }), - adminClient.realms.findOne({ realm }), id ? adminClient.components.findOne({ id }) : Promise.resolve(), ]), - ([providers, realm, data]) => { + ([providers, data]) => { setProvider(providers.find((p) => p.id === providerId)); - setParentId(realm?.id || ""); reset(data || { providerId }); }, [], @@ -71,7 +68,7 @@ export default function DetailProvider() { const updatedComponent = { ...component, subType: subTab, - parentId, + parentId: realmRepresentation?.id, providerType: "org.keycloak.services.clientregistration.policy.ClientRegistrationPolicy", providerId, diff --git a/js/apps/admin-ui/src/components/roles-list/RolesList.tsx b/js/apps/admin-ui/src/components/roles-list/RolesList.tsx index 6c3ea5dda4bd..16064e899d29 100644 --- a/js/apps/admin-ui/src/components/roles-list/RolesList.tsx +++ b/js/apps/admin-ui/src/components/roles-list/RolesList.tsx @@ -1,18 +1,15 @@ -import type RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation"; import type RoleRepresentation from "@keycloak/keycloak-admin-client/lib/defs/roleRepresentation"; +import { HelpItem } from "@keycloak/keycloak-ui-shared"; import { AlertVariant, Button, ButtonVariant } from "@patternfly/react-core"; import { useState } from "react"; import { useTranslation } from "react-i18next"; import { Link, To, useNavigate } from "react-router-dom"; -import { HelpItem } from "@keycloak/keycloak-ui-shared"; import { useAdminClient } from "../../admin-client"; import { useRealm } from "../../context/realm-context/RealmContext"; import { toRealmSettings } from "../../realm-settings/routes/RealmSettings"; import { emptyFormatter, upperCaseFormatter } from "../../util"; -import { useFetch } from "../../utils/useFetch"; import { useAlerts } from "../alert/Alerts"; import { useConfirmDialog } from "../confirm-dialog/ConfirmDialog"; -import { KeycloakSpinner } from "../keycloak-spinner/KeycloakSpinner"; import { ListEmptyState } from "../list-empty-state/ListEmptyState"; import { Action, KeycloakDataTable } from "../table-toolbar/KeycloakDataTable"; @@ -75,19 +72,10 @@ export const RolesList = ({ const { t } = useTranslation(); const navigate = useNavigate(); const { addAlert, addError } = useAlerts(); - const { realm: realmName } = useRealm(); - const [realm, setRealm] = useState(); + const { realmRepresentation: realm } = useRealm(); const [selectedRole, setSelectedRole] = useState(); - useFetch( - () => adminClient.realms.findOne({ realm: realmName }), - (realm) => { - setRealm(realm); - }, - [], - ); - const [toggleDeleteDialog, DeleteConfirm] = useConfirmDialog({ titleKey: "roleDeleteConfirm", messageKey: t("roleDeleteConfirmDialog", { @@ -114,10 +102,6 @@ export const RolesList = ({ }, }); - if (!realm) { - return ; - } - return ( <> @@ -146,7 +130,7 @@ export const RolesList = ({ onRowClick: (role) => { setSelectedRole(role); if ( - realm!.defaultRole && + realm?.defaultRole && role.name === realm!.defaultRole!.name ) { addAlert( @@ -165,7 +149,7 @@ export const RolesList = ({ cellRenderer: (row) => ( diff --git a/js/apps/admin-ui/src/components/users/UserDataTable.tsx b/js/apps/admin-ui/src/components/users/UserDataTable.tsx index cb527ed05aae..d6109cd447a6 100644 --- a/js/apps/admin-ui/src/components/users/UserDataTable.tsx +++ b/js/apps/admin-ui/src/components/users/UserDataTable.tsx @@ -1,5 +1,4 @@ import type ComponentRepresentation from "@keycloak/keycloak-admin-client/lib/defs/componentRepresentation"; -import type RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation"; import type { UserProfileConfig } from "@keycloak/keycloak-admin-client/lib/defs/userProfileMetadata"; import type UserRepresentation from "@keycloak/keycloak-admin-client/lib/defs/userRepresentation"; import { @@ -98,11 +97,10 @@ export function UserDataTable() { const { t } = useTranslation(); const { addAlert, addError } = useAlerts(); - const { realm: realmName } = useRealm(); + const { realm: realmName, realmRepresentation: realm } = useRealm(); const navigate = useNavigate(); const [userStorage, setUserStorage] = useState(); const [searchUser, setSearchUser] = useState(""); - const [realm, setRealm] = useState(); const [selectedRows, setSelectedRows] = useState([]); const [searchType, setSearchType] = useState("default"); const [searchDropdownOpen, setSearchDropdownOpen] = useState(false); @@ -122,22 +120,16 @@ export function UserDataTable() { try { return await Promise.all([ adminClient.components.find(testParams), - adminClient.realms.findOne({ realm: realmName }), adminClient.users.getProfile(), ]); } catch { - return [[], {}, {}] as [ - ComponentRepresentation[], - RealmRepresentation | undefined, - UserProfileConfig, - ]; + return [[], {}] as [ComponentRepresentation[], UserProfileConfig]; } }, - ([storageProviders, realm, profile]) => { + ([storageProviders, profile]) => { setUserStorage( storageProviders.filter((p) => p.config?.enabled?.[0] === "true"), ); - setRealm(realm); setProfile(profile); }, [], diff --git a/js/apps/admin-ui/src/context/realm-context/RealmContext.tsx b/js/apps/admin-ui/src/context/realm-context/RealmContext.tsx index a0ac8e39a3a5..7faf4d3116b6 100644 --- a/js/apps/admin-ui/src/context/realm-context/RealmContext.tsx +++ b/js/apps/admin-ui/src/context/realm-context/RealmContext.tsx @@ -1,4 +1,4 @@ -import { PropsWithChildren, useEffect, useMemo } from "react"; +import { PropsWithChildren, useEffect, useMemo, useState } from "react"; import { useMatch } from "react-router-dom"; import { createNamedContext, @@ -7,9 +7,13 @@ import { } from "@keycloak/keycloak-ui-shared"; import { useAdminClient } from "../../admin-client"; import { DashboardRouteWithRealm } from "../../dashboard/routes/Dashboard"; +import RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation"; +import { useFetch } from "../../utils/useFetch"; type RealmContextType = { realm: string; + realmRepresentation?: RealmRepresentation; + refresh: () => void; }; export const RealmContext = createNamedContext( @@ -20,6 +24,10 @@ export const RealmContext = createNamedContext( export const RealmContextProvider = ({ children }: PropsWithChildren) => { const { adminClient } = useAdminClient(); const { environment } = useEnvironment(); + const [key, setKey] = useState(0); + const refresh = () => setKey(key + 1); + const [realmRepresentation, setRealmRepresentation] = + useState(); const routeMatch = useMatch({ path: DashboardRouteWithRealm.path, @@ -34,11 +42,16 @@ export const RealmContextProvider = ({ children }: PropsWithChildren) => { // Configure admin client to use selected realm when it changes. useEffect(() => adminClient.setConfig({ realmName: realm }), [realm]); - - const value = useMemo(() => ({ realm }), [realm]); + useFetch( + () => adminClient.realms.findOne({ realm }), + setRealmRepresentation, + [realm, key], + ); return ( - {children} + + {children} + ); }; diff --git a/js/apps/admin-ui/src/dashboard/Dashboard.tsx b/js/apps/admin-ui/src/dashboard/Dashboard.tsx index 03eeb0760686..a7ddccf83a4a 100644 --- a/js/apps/admin-ui/src/dashboard/Dashboard.tsx +++ b/js/apps/admin-ui/src/dashboard/Dashboard.tsx @@ -1,7 +1,6 @@ import FeatureRepresentation, { FeatureType, } from "@keycloak/keycloak-admin-client/lib/defs/featureRepresentation"; -import type RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation"; import { HelpItem, label, useEnvironment } from "@keycloak/keycloak-ui-shared"; import { ActionList, @@ -32,9 +31,8 @@ import { TextVariants, Title, } from "@patternfly/react-core"; -import { useMemo, useState } from "react"; +import { useMemo } from "react"; import { useTranslation } from "react-i18next"; -import { useAdminClient } from "../admin-client"; import { KeycloakSpinner } from "../components/keycloak-spinner/KeycloakSpinner"; import { RoutableTabs, @@ -43,7 +41,6 @@ import { import { useRealm } from "../context/realm-context/RealmContext"; import { useServerInfo } from "../context/server-info/ServerInfoProvider"; import helpUrls from "../help-urls"; -import { useFetch } from "../utils/useFetch"; import useLocaleSort, { mapByKey } from "../utils/useLocaleSort"; import { ProviderInfo } from "./ProviderInfo"; import { DashboardTab, toDashboard } from "./routes/Dashboard"; @@ -51,14 +48,11 @@ import { DashboardTab, toDashboard } from "./routes/Dashboard"; import "./dashboard.css"; const EmptyDashboard = () => { - const { adminClient } = useAdminClient(); const { environment } = useEnvironment(); const { t } = useTranslation(); - const { realm } = useRealm(); - const [realmInfo, setRealmInfo] = useState(); + const { realm, realmRepresentation: realmInfo } = useRealm(); const brandImage = environment.logo ? environment.logo : "/icon.svg"; - useFetch(() => adminClient.realms.findOne({ realm }), setRealmInfo, []); const realmDisplayInfo = label(t, realmInfo?.displayName, realm); return ( @@ -100,13 +94,10 @@ const FeatureItem = ({ feature }: FeatureItemProps) => { }; const Dashboard = () => { - const { adminClient } = useAdminClient(); - const { t } = useTranslation(); - const { realm } = useRealm(); + const { realm, realmRepresentation: realmInfo } = useRealm(); const serverInfo = useServerInfo(); const localeSort = useLocaleSort(); - const [realmInfo, setRealmInfo] = useState(); const sortedFeatures = useMemo( () => localeSort(serverInfo.features ?? [], mapByKey("name")), @@ -131,8 +122,6 @@ const Dashboard = () => { }), ); - useFetch(() => adminClient.realms.findOne({ realm }), setRealmInfo, []); - const realmDisplayInfo = label(t, realmInfo?.displayName, realm); const welcomeTab = useTab("welcome"); diff --git a/js/apps/admin-ui/src/groups/Members.tsx b/js/apps/admin-ui/src/groups/Members.tsx index f1bafb6be47b..c2634f7d88c5 100644 --- a/js/apps/admin-ui/src/groups/Members.tsx +++ b/js/apps/admin-ui/src/groups/Members.tsx @@ -1,12 +1,7 @@ import type GroupRepresentation from "@keycloak/keycloak-admin-client/lib/defs/groupRepresentation"; import type UserRepresentation from "@keycloak/keycloak-admin-client/lib/defs/userRepresentation"; import { SubGroupQuery } from "@keycloak/keycloak-admin-client/lib/resources/groups"; -import { - AlertVariant, - Button, - Checkbox, - ToolbarItem, -} from "@patternfly/react-core"; +import { Button, Checkbox, ToolbarItem } from "@patternfly/react-core"; import { Dropdown, DropdownItem, @@ -155,7 +150,21 @@ export const Members = () => { <> {addMembers && ( + await adminClient.groups.listMembers({ id: id! }) + } + onAdd={async (selectedRows) => { + try { + await Promise.all( + selectedRows.map((user) => + adminClient.users.addToGroup({ id: user.id!, groupId: id! }), + ), + ); + addAlert(t("usersAdded", { count: selectedRows.length })); + } catch (error) { + addError("usersAddedError", error); + } + }} onClose={() => { setAddMembers(false); refresh(); @@ -218,7 +227,6 @@ export const Members = () => { setIsKebabOpen(false); addAlert( t("usersLeft", { count: selectedRows.length }), - AlertVariant.success, ); } catch (error) { addError("usersLeftError", error); @@ -246,10 +254,7 @@ export const Members = () => { id: user.id!, groupId: id!, }); - addAlert( - t("usersLeft", { count: 1 }), - AlertVariant.success, - ); + addAlert(t("usersLeft", { count: 1 })); } catch (error) { addError("usersLeftError", error); } diff --git a/js/apps/admin-ui/src/groups/MembersModal.tsx b/js/apps/admin-ui/src/groups/MembersModal.tsx index fd3621763b0c..b2a92faf2c35 100644 --- a/js/apps/admin-ui/src/groups/MembersModal.tsx +++ b/js/apps/admin-ui/src/groups/MembersModal.tsx @@ -1,10 +1,5 @@ import type UserRepresentation from "@keycloak/keycloak-admin-client/lib/defs/userRepresentation"; -import { - AlertVariant, - Button, - Modal, - ModalVariant, -} from "@patternfly/react-core"; +import { Button, Modal, ModalVariant } from "@patternfly/react-core"; import { differenceBy } from "lodash-es"; import { useState } from "react"; import { useTranslation } from "react-i18next"; @@ -15,19 +10,24 @@ import { KeycloakDataTable } from "../components/table-toolbar/KeycloakDataTable import { emptyFormatter } from "../util"; type MemberModalProps = { - groupId: string; + membersQuery: () => Promise; + onAdd: (users: UserRepresentation[]) => Promise; onClose: () => void; }; -export const MemberModal = ({ groupId, onClose }: MemberModalProps) => { +export const MemberModal = ({ + membersQuery, + onAdd, + onClose, +}: MemberModalProps) => { const { adminClient } = useAdminClient(); const { t } = useTranslation(); - const { addAlert, addError } = useAlerts(); + const { addError } = useAlerts(); const [selectedRows, setSelectedRows] = useState([]); const loader = async (first?: number, max?: number, search?: string) => { - const members = await adminClient.groups.listMembers({ id: groupId }); + const members = await membersQuery(); const params: { [name: string]: string | number } = { first: first!, max: max! + members.length, @@ -47,7 +47,7 @@ export const MemberModal = ({ groupId, onClose }: MemberModalProps) => { { key="confirm" variant="primary" onClick={async () => { - try { - await Promise.all( - selectedRows.map((user) => - adminClient.users.addToGroup({ id: user.id!, groupId }), - ), - ); - onClose(); - addAlert( - t("usersAdded", { count: selectedRows.length }), - AlertVariant.success, - ); - } catch (error) { - addError("usersAddedError", error); - } + await onAdd(selectedRows); + onClose(); }} > {t("add")} diff --git a/js/apps/admin-ui/src/identity-providers/IdentityProvidersSection.tsx b/js/apps/admin-ui/src/identity-providers/IdentityProvidersSection.tsx index b378ddc1b0ac..110d001d1b4b 100644 --- a/js/apps/admin-ui/src/identity-providers/IdentityProvidersSection.tsx +++ b/js/apps/admin-ui/src/identity-providers/IdentityProvidersSection.tsx @@ -1,5 +1,6 @@ import type IdentityProviderRepresentation from "@keycloak/keycloak-admin-client/lib/defs/identityProviderRepresentation"; import type { IdentityProvidersQuery } from "@keycloak/keycloak-admin-client/lib/resources/identityProviders"; +import { IconMapper } from "@keycloak/keycloak-ui-shared"; import { AlertVariant, Badge, @@ -21,11 +22,11 @@ import { DropdownItem, DropdownToggle, } from "@patternfly/react-core/deprecated"; +import { IFormatterValueType } from "@patternfly/react-table"; import { groupBy, sortBy } from "lodash-es"; import { Fragment, useState } from "react"; import { useTranslation } from "react-i18next"; import { Link, useNavigate } from "react-router-dom"; -import { IconMapper } from "@keycloak/keycloak-ui-shared"; import { useAdminClient } from "../admin-client"; import { useAlerts } from "../components/alert/Alerts"; import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog"; @@ -272,6 +273,15 @@ export default function IdentityProvidersSection() { displayKey: "providerDetails", cellFormatters: [upperCaseFormatter()], }, + { + name: "config['kc.org']", + displayKey: "linkedOrganization", + cellFormatters: [ + (data?: IFormatterValueType) => { + return data ? "X" : "—"; + }, + ], + }, ]} /> )} diff --git a/js/apps/admin-ui/src/organizations/DetailOraganzationHeader.tsx b/js/apps/admin-ui/src/organizations/DetailOraganzationHeader.tsx new file mode 100644 index 000000000000..cc298fa100f8 --- /dev/null +++ b/js/apps/admin-ui/src/organizations/DetailOraganzationHeader.tsx @@ -0,0 +1,90 @@ +import { ButtonVariant, DropdownItem } from "@patternfly/react-core"; +import { ViewHeader } from "../components/view-header/ViewHeader"; +import { useTranslation } from "react-i18next"; +import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog"; +import { useAdminClient } from "../admin-client"; +import { useNavigate } from "react-router-dom"; +import { useAlerts } from "../components/alert/Alerts"; +import { Controller, useFormContext, useWatch } from "react-hook-form"; +import { toOrganizations } from "./routes/Organizations"; +import { useRealm } from "../context/realm-context/RealmContext"; + +type DetailOrganizationHeaderProps = { + save: () => void; +}; + +export const DetailOrganizationHeader = ({ + save, +}: DetailOrganizationHeaderProps) => { + const { adminClient } = useAdminClient(); + const { realm } = useRealm(); + const navigate = useNavigate(); + + const { t } = useTranslation(); + const { addAlert, addError } = useAlerts(); + + const id = useWatch({ name: "id" }); + const name = useWatch({ name: "name" }); + + const { setValue } = useFormContext(); + + const [toggleDisableDialog, DisableConfirm] = useConfirmDialog({ + titleKey: "disableConfirmOrganizationTitle", + messageKey: "disableConfirmOrganization", + continueButtonLabel: "disable", + onConfirm: () => { + setValue("enabled", false); + save(); + }, + }); + + const [toggleDeleteDialog, DeleteConfirm] = useConfirmDialog({ + titleKey: "organizationDelete", + messageKey: "organizationDeleteConfirm", + continueButtonLabel: "delete", + continueButtonVariant: ButtonVariant.danger, + onConfirm: async () => { + try { + await adminClient.organizations.delById({ id }); + addAlert(t("organizationDeletedSuccess")); + navigate(toOrganizations({ realm })); + } catch (error) { + addError("organizationDeleteError", error); + } + }, + }); + + return ( + ( + <> + + + + {t("delete")} + , + ]} + isEnabled={value} + onToggle={(value) => { + if (!value) { + toggleDisableDialog(); + } else { + onChange(value); + save(); + } + }} + /> + + )} + /> + ); +}; diff --git a/js/apps/admin-ui/src/organizations/DetailOrganization.tsx b/js/apps/admin-ui/src/organizations/DetailOrganization.tsx new file mode 100644 index 000000000000..49c0de64023a --- /dev/null +++ b/js/apps/admin-ui/src/organizations/DetailOrganization.tsx @@ -0,0 +1,166 @@ +import { FormSubmitButton } from "@keycloak/keycloak-ui-shared"; +import { + ActionGroup, + Button, + PageSection, + Tab, + TabTitleText, +} from "@patternfly/react-core"; +import { FormProvider, useForm } from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import { useAdminClient } from "../admin-client"; +import { useAlerts } from "../components/alert/Alerts"; +import { FormAccess } from "../components/form/FormAccess"; +import { AttributesForm } from "../components/key-value-form/AttributeForm"; +import { arrayToKeyValue } from "../components/key-value-form/key-value-convert"; +import { + RoutableTabs, + useRoutableTab, +} from "../components/routable-tabs/RoutableTabs"; +import { useRealm } from "../context/realm-context/RealmContext"; +import { useFetch } from "../utils/useFetch"; +import { useParams } from "../utils/useParams"; +import { DetailOrganizationHeader } from "./DetailOraganzationHeader"; +import { Members } from "./Members"; +import { + OrganizationForm, + OrganizationFormType, + convertToOrg, +} from "./OrganizationForm"; +import { + EditOrganizationParams, + OrganizationTab, + toEditOrganization, +} from "./routes/EditOrganization"; +import { IdentityProviders } from "./IdentityProviders"; + +export default function DetailOrganization() { + const { adminClient } = useAdminClient(); + const { addAlert, addError } = useAlerts(); + + const { realm } = useRealm(); + const { id } = useParams(); + const { t } = useTranslation(); + + const form = useForm(); + + const save = async (org: OrganizationFormType) => { + try { + const organization = convertToOrg(org); + await adminClient.organizations.updateById({ id }, organization); + addAlert(t("organizationSaveSuccess")); + } catch (error) { + addError("organizationSaveError", error); + } + }; + + useFetch( + () => adminClient.organizations.findOne({ id }), + (org) => { + if (!org) { + throw new Error(t("notFound")); + } + form.reset({ + ...org, + domains: org.domains?.map((d) => d.name), + attributes: arrayToKeyValue(org.attributes), + }); + }, + [id], + ); + + const useTab = (tab: OrganizationTab) => + useRoutableTab( + toEditOrganization({ + realm, + id, + tab, + }), + ); + + const settingsTab = useTab("settings"); + const attributesTab = useTab("attributes"); + const membersTab = useTab("members"); + const identityProvidersTab = useTab("identityProviders"); + + return ( + + + save(form.getValues())} /> + + {t("settings")}} + {...settingsTab} + > + + + + + + {t("save")} + + + + + + + {t("attributes")}} + {...attributesTab} + > + + + form.reset({ + ...form.getValues(), + }) + } + name="attributes" + /> + + + {t("members")}} + {...membersTab} + > + + + {t("identityProviders")}} + {...identityProvidersTab} + > + + + + + + ); +} diff --git a/js/apps/admin-ui/src/organizations/IdentityProviderSelect.tsx b/js/apps/admin-ui/src/organizations/IdentityProviderSelect.tsx new file mode 100644 index 000000000000..b0ca2947b222 --- /dev/null +++ b/js/apps/admin-ui/src/organizations/IdentityProviderSelect.tsx @@ -0,0 +1,222 @@ +import IdentityProviderRepresentation from "@keycloak/keycloak-admin-client/lib/defs/identityProviderRepresentation"; +import { IdentityProvidersQuery } from "@keycloak/keycloak-admin-client/lib/resources/identityProviders"; +import { FormErrorText, HelpItem } from "@keycloak/keycloak-ui-shared"; +import { + Button, + Chip, + ChipGroup, + FormGroup, + MenuToggle, + Select, + SelectList, + SelectOption, + TextInputGroup, + TextInputGroupMain, + TextInputGroupUtilities, +} from "@patternfly/react-core"; +import { TimesIcon } from "@patternfly/react-icons"; +import { debounce } from "lodash-es"; +import { useCallback, useRef, useState } from "react"; +import { Controller, useFormContext } from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import { useAdminClient } from "../admin-client"; +import { ComponentProps } from "../components/dynamic/components"; +import { KeycloakSpinner } from "../components/keycloak-spinner/KeycloakSpinner"; +import { useFetch } from "../utils/useFetch"; +import useToggle from "../utils/useToggle"; + +type IdentityProviderSelectProps = ComponentProps & { + variant?: "typeaheadMulti" | "typeahead"; + isRequired?: boolean; +}; + +export const IdentityProviderSelect = ({ + name, + label, + helpText, + defaultValue, + isRequired, + variant = "typeahead", + isDisabled, +}: IdentityProviderSelectProps) => { + const { adminClient } = useAdminClient(); + + const { t } = useTranslation(); + const { + control, + getValues, + formState: { errors }, + } = useFormContext(); + const values: string[] | undefined = getValues(name!); + + const [open, toggleOpen, setOpen] = useToggle(); + const [inputValue, setInputValue] = useState(""); + const textInputRef = useRef(); + const [idps, setIdps] = useState< + (IdentityProviderRepresentation | undefined)[] + >([]); + const [search, setSearch] = useState(""); + + const debounceFn = useCallback(debounce(setSearch, 1000), []); + + useFetch( + async () => { + const params: IdentityProvidersQuery = { + max: 20, + }; + if (search) { + params.search = search; + } + + const idps = await adminClient.identityProviders.find(params); + return idps.filter((i) => !i.config?.["kc.org"]); + }, + setIdps, + [search], + ); + + const convert = ( + identityProviders: (IdentityProviderRepresentation | undefined)[], + ) => { + const options = identityProviders.map((option) => ( + + {option!.alias} + + )); + if (options.length === 0) { + return {t("noResultsFound")}; + } + return options; + }; + + if (!idps) { + return ; + } + return ( + + ) : undefined + } + fieldId={name!} + > + + isRequired && value.filter((i) => i !== undefined).length === 0 + ? t("required") + : undefined, + }} + render={({ field }) => ( + + )} + /> + {errors[name!] && } + + ); +}; diff --git a/js/apps/admin-ui/src/organizations/IdentityProviders.tsx b/js/apps/admin-ui/src/organizations/IdentityProviders.tsx new file mode 100644 index 000000000000..1d6cf3628c4e --- /dev/null +++ b/js/apps/admin-ui/src/organizations/IdentityProviders.tsx @@ -0,0 +1,196 @@ +import IdentityProviderRepresentation from "@keycloak/keycloak-admin-client/lib/defs/identityProviderRepresentation"; +import { + Button, + ButtonVariant, + PageSection, + Switch, + ToolbarItem, +} from "@patternfly/react-core"; +import { BellIcon } from "@patternfly/react-icons"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useParams } from "react-router-dom"; +import { useAdminClient } from "../admin-client"; +import { useAlerts } from "../components/alert/Alerts"; +import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog"; +import { ListEmptyState } from "../components/list-empty-state/ListEmptyState"; +import { KeycloakDataTable } from "../components/table-toolbar/KeycloakDataTable"; +import { useFetch } from "../utils/useFetch"; +import useToggle from "../utils/useToggle"; +import { LinkIdentityProviderModal } from "./LinkIdentityProviderModal"; +import { EditOrganizationParams } from "./routes/EditOrganization"; + +type ShownOnLoginPageCheckProps = { + row: IdentityProviderRepresentation; + refresh: () => void; +}; + +const ShownOnLoginPageCheck = ({ + row, + refresh, +}: ShownOnLoginPageCheckProps) => { + const { adminClient } = useAdminClient(); + const { addAlert, addError } = useAlerts(); + const { t } = useTranslation(); + + const toggle = async (value: boolean) => { + try { + await adminClient.identityProviders.update( + { alias: row.alias! }, + { + ...row, + config: { + ...row.config, + "kc.org.broker.public": `${value}`, + }, + }, + ); + addAlert(t("linkUpdatedSuccessful")); + + refresh(); + } catch (error) { + addError("linkUpdatedError", error); + } + }; + + return ( + toggle(value)} + /> + ); +}; + +export const IdentityProviders = () => { + const { adminClient } = useAdminClient(); + const { t } = useTranslation(); + const { id: orgId } = useParams(); + const { addAlert, addError } = useAlerts(); + + const [key, setKey] = useState(0); + const refresh = () => setKey(key + 1); + + const [hasProviders, setHasProviders] = useState(false); + const [selectedRow, setSelectedRow] = + useState(); + const [open, toggleOpen] = useToggle(); + + useFetch( + async () => adminClient.identityProviders.find({ max: 1 }), + (providers) => { + setHasProviders(providers.length === 1); + }, + [], + ); + + const loader = () => + adminClient.organizations.listIdentityProviders({ orgId: orgId! }); + + const [toggleUnlinkDialog, UnlinkConfirm] = useConfirmDialog({ + titleKey: "identityProviderUnlink", + messageKey: "identityProviderUnlinkConfirm", + continueButtonLabel: "unLinkIdentityProvider", + continueButtonVariant: ButtonVariant.danger, + onConfirm: async () => { + try { + await adminClient.organizations.unLinkIdp({ + orgId: orgId!, + alias: selectedRow!.alias! as string, + }); + setSelectedRow(undefined); + addAlert(t("unLinkSuccessful")); + refresh(); + } catch (error) { + addError("unLinkError", error); + } + }, + }); + + return ( + + + {open && ( + { + toggleOpen(); + refresh(); + }} + /> + )} + {!hasProviders ? ( + + ) : ( + + + + } + actions={[ + { + title: t("edit"), + onRowClick: (row) => { + setSelectedRow(row); + toggleOpen(); + }, + }, + { + title: t("unLinkIdentityProvider"), + onRowClick: (row) => { + setSelectedRow(row); + toggleUnlinkDialog(); + }, + }, + ]} + columns={[ + { + name: "alias", + }, + { + name: "config['kc.org.domain']", + displayKey: "domain", + }, + { + name: "providerId", + displayKey: "providerDetails", + }, + { + name: "config['kc.org.broker.public']", + displayKey: "shownOnLoginPage", + cellRenderer: (row) => ( + + ), + }, + ]} + emptyState={ + + } + /> + )} + + ); +}; diff --git a/js/apps/admin-ui/src/organizations/InviteMemberModal.tsx b/js/apps/admin-ui/src/organizations/InviteMemberModal.tsx new file mode 100644 index 000000000000..8097435e5b53 --- /dev/null +++ b/js/apps/admin-ui/src/organizations/InviteMemberModal.tsx @@ -0,0 +1,86 @@ +import { FormSubmitButton, TextControl } from "@keycloak/keycloak-ui-shared"; +import { + Button, + ButtonVariant, + Form, + Modal, + ModalVariant, +} from "@patternfly/react-core"; +import { FormProvider, useForm } from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import { useAdminClient } from "../admin-client"; +import { useAlerts } from "../components/alert/Alerts"; + +type InviteMemberModalProps = { + orgId: string; + onClose: () => void; +}; + +export const InviteMemberModal = ({ + orgId, + onClose, +}: InviteMemberModalProps) => { + const { adminClient } = useAdminClient(); + const { addAlert, addError } = useAlerts(); + + const { t } = useTranslation(); + const form = useForm>(); + const { handleSubmit, formState } = form; + + const submitForm = async (data: Record) => { + try { + const formData = new FormData(); + for (const key in data) { + formData.append(key, data[key]); + } + await adminClient.organizations.invite({ orgId }, formData); + addAlert(t("inviteSent")); + onClose(); + } catch (error) { + addError("inviteSentError", error); + } + }; + + return ( + + {t("send")} + , + , + ]} + > + +
+ + + + +
+
+ ); +}; diff --git a/js/apps/admin-ui/src/organizations/LinkIdentityProviderModal.tsx b/js/apps/admin-ui/src/organizations/LinkIdentityProviderModal.tsx new file mode 100644 index 000000000000..22531614d59b --- /dev/null +++ b/js/apps/admin-ui/src/organizations/LinkIdentityProviderModal.tsx @@ -0,0 +1,152 @@ +import IdentityProviderRepresentation from "@keycloak/keycloak-admin-client/lib/defs/identityProviderRepresentation"; +import { FormSubmitButton, SelectControl } from "@keycloak/keycloak-ui-shared"; +import { + Button, + ButtonVariant, + Form, + Modal, + ModalVariant, +} from "@patternfly/react-core"; +import { useEffect } from "react"; +import { FormProvider, useForm, useFormContext } from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import { useAdminClient } from "../admin-client"; +import { DefaultSwitchControl } from "../components/SwitchControl"; +import { useAlerts } from "../components/alert/Alerts"; +import { + convertAttributeNameToForm, + convertFormValuesToObject, + convertToFormValues, +} from "../util"; +import { IdentityProviderSelect } from "./IdentityProviderSelect"; +import { OrganizationFormType } from "./OrganizationForm"; + +type LinkIdentityProviderModalProps = { + orgId: string; + identityProvider?: IdentityProviderRepresentation; + onClose: () => void; +}; + +type LinkRepresentation = { + alias: string[] | string; + config: { + "kc.org.domain": string; + "kc.org.broker.public": string; + }; +}; + +export const LinkIdentityProviderModal = ({ + orgId, + identityProvider, + onClose, +}: LinkIdentityProviderModalProps) => { + const { adminClient } = useAdminClient(); + const { t } = useTranslation(); + const { addAlert, addError } = useAlerts(); + + const form = useForm({ mode: "onChange" }); + const { handleSubmit, formState, setValue } = form; + const { getValues } = useFormContext(); + + useEffect( + () => + convertToFormValues( + { ...identityProvider, alias: [identityProvider?.alias] }, + setValue, + ), + [], + ); + + const submitForm = async (data: LinkRepresentation) => { + try { + const foundIdentityProvider = await adminClient.identityProviders.findOne( + { + alias: data.alias[0], + }, + ); + if (!foundIdentityProvider) { + throw new Error(t("notFound")); + } + const { config } = convertFormValuesToObject(data); + foundIdentityProvider.config = { + ...foundIdentityProvider.config, + ...config, + }; + await adminClient.identityProviders.update( + { alias: data.alias[0] }, + foundIdentityProvider, + ); + + if (!identityProvider) { + await adminClient.organizations.linkIdp({ + orgId, + alias: data.alias[0], + }); + } + addAlert( + t(!identityProvider ? "linkSuccessful" : "linkUpdatedSuccessful"), + ); + onClose(); + } catch (error) { + addError(!identityProvider ? "linkError" : "linkUpdatedError", error); + } + }; + + return ( + + {t("save")} + , + , + ]} + > + +
+ + ({ key: d, value: d })), + ]} + menuAppendTo="parent" + /> + + +
+
+ ); +}; diff --git a/js/apps/admin-ui/src/organizations/Members.tsx b/js/apps/admin-ui/src/organizations/Members.tsx new file mode 100644 index 000000000000..9a9912186b1d --- /dev/null +++ b/js/apps/admin-ui/src/organizations/Members.tsx @@ -0,0 +1,201 @@ +import UserRepresentation from "@keycloak/keycloak-admin-client/lib/defs/userRepresentation"; +import { + Button, + Dropdown, + DropdownItem, + DropdownList, + MenuToggle, + PageSection, + ToolbarItem, +} from "@patternfly/react-core"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Link } from "react-router-dom"; +import { useAdminClient } from "../admin-client"; +import { useAlerts } from "../components/alert/Alerts"; +import { ListEmptyState } from "../components/list-empty-state/ListEmptyState"; +import { KeycloakDataTable } from "../components/table-toolbar/KeycloakDataTable"; +import { useRealm } from "../context/realm-context/RealmContext"; +import { MemberModal } from "../groups/MembersModal"; +import { toUser } from "../user/routes/User"; +import { useParams } from "../utils/useParams"; +import useToggle from "../utils/useToggle"; +import { InviteMemberModal } from "./InviteMemberModal"; +import { EditOrganizationParams } from "./routes/EditOrganization"; + +const UserDetailLink = (user: any) => { + const { realm } = useRealm(); + return ( + + {user.username} + + ); +}; + +export const Members = () => { + const { t } = useTranslation(); + const { adminClient } = useAdminClient(); + const { id: orgId } = useParams(); + 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( + [], + ); + + const loader = (first?: number, max?: number, search?: string) => + adminClient.organizations.listMembers({ orgId, first, max, search }); + + const removeMember = async (selectedMembers: UserRepresentation[]) => { + try { + await Promise.all( + selectedMembers.map((user) => + adminClient.organizations.delMember({ + orgId, + userId: user.id!, + }), + ), + ); + addAlert(t("organizationUsersLeft", { count: selectedMembers.length })); + } catch (error) { + addError("organizationUsersLeftError", error); + } + + refresh(); + }; + return ( + + {openAddMembers && ( + + await adminClient.organizations.listMembers({ orgId }) + } + onAdd={async (selectedRows) => { + try { + await Promise.all( + selectedRows.map((user) => + adminClient.organizations.addMember({ + orgId, + userId: user.id!, + }), + ), + ); + addAlert( + t("organizationUsersAdded", { count: selectedRows.length }), + ); + } catch (error) { + addError("organizationUsersAddedError", error); + } + }} + onClose={() => { + toggleAddMembers(); + refresh(); + }} + /> + )} + {openInviteMembers && ( + + )} + setSelectedMembers([...members])} + canSelectAll + toolbarItem={ + <> + + ( + + {t("addMember")} + + )} + isOpen={open} + > + + { + toggleAddMembers(); + toggle(); + }} + > + {t("addRealmUser")} + + { + toggleInviteMembers(); + toggle(); + }} + > + {t("inviteMember")} + + + + + + + + + } + actions={[ + { + title: t("remove"), + onRowClick: async (member) => { + await removeMember([member]); + }, + }, + ]} + columns={[ + { + name: "username", + cellRenderer: UserDetailLink, + }, + { + name: "email", + }, + { + name: "firstName", + }, + { + name: "lastName", + }, + ]} + emptyState={ + + } + /> + + ); +}; diff --git a/js/apps/admin-ui/src/organizations/NewOrganization.tsx b/js/apps/admin-ui/src/organizations/NewOrganization.tsx new file mode 100644 index 000000000000..975e5af846be --- /dev/null +++ b/js/apps/admin-ui/src/organizations/NewOrganization.tsx @@ -0,0 +1,65 @@ +import { FormSubmitButton } from "@keycloak/keycloak-ui-shared"; +import { ActionGroup, Button, PageSection } from "@patternfly/react-core"; +import { FormProvider, useForm } from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import { Link, useNavigate } from "react-router-dom"; +import { useAdminClient } from "../admin-client"; +import { useAlerts } from "../components/alert/Alerts"; +import { FormAccess } from "../components/form/FormAccess"; +import { ViewHeader } from "../components/view-header/ViewHeader"; +import { useRealm } from "../context/realm-context/RealmContext"; +import { + OrganizationForm, + OrganizationFormType, + convertToOrg, +} from "./OrganizationForm"; +import { toEditOrganization } from "./routes/EditOrganization"; +import { toOrganizations } from "./routes/Organizations"; + +export default function NewOrganization() { + const { adminClient } = useAdminClient(); + const { addAlert, addError } = useAlerts(); + const { t } = useTranslation(); + const navigate = useNavigate(); + const { realm } = useRealm(); + const form = useForm(); + const { handleSubmit, formState } = form; + + const save = async (org: OrganizationFormType) => { + try { + const organization = convertToOrg(org); + const { id } = await adminClient.organizations.create(organization); + addAlert(t("organizationSaveSuccess")); + navigate(toEditOrganization({ realm, id, tab: "settings" })); + } catch (error) { + addError("organizationSaveError", error); + } + }; + + return ( + <> + + + + + + + + {t("save")} + + + + + + + + ); +} diff --git a/js/apps/admin-ui/src/organizations/OrganizationForm.tsx b/js/apps/admin-ui/src/organizations/OrganizationForm.tsx new file mode 100644 index 000000000000..ce53eba844ec --- /dev/null +++ b/js/apps/admin-ui/src/organizations/OrganizationForm.tsx @@ -0,0 +1,55 @@ +import OrganizationRepresentation from "@keycloak/keycloak-admin-client/lib/defs/organizationRepresentation"; +import { + HelpItem, + TextAreaControl, + TextControl, +} from "@keycloak/keycloak-ui-shared"; +import { FormGroup } from "@patternfly/react-core"; +import { useTranslation } from "react-i18next"; +import { AttributeForm } from "../components/key-value-form/AttributeForm"; +import { MultiLineInput } from "../components/multi-line-input/MultiLineInput"; +import { keyValueToArray } from "../components/key-value-form/key-value-convert"; + +export type OrganizationFormType = AttributeForm & + Omit & { + domains?: string[]; + }; + +export const convertToOrg = ( + org: OrganizationFormType, +): OrganizationRepresentation => ({ + ...org, + domains: org.domains?.map((d) => ({ name: d, verified: false })), + attributes: keyValueToArray(org.attributes), +}); + +export const OrganizationForm = () => { + const { t } = useTranslation(); + return ( + <> + + + } + > + + + + + ); +}; diff --git a/js/apps/admin-ui/src/organizations/OrganizationsSection.tsx b/js/apps/admin-ui/src/organizations/OrganizationsSection.tsx new file mode 100644 index 000000000000..22ae4ae6771b --- /dev/null +++ b/js/apps/admin-ui/src/organizations/OrganizationsSection.tsx @@ -0,0 +1,168 @@ +import OrganizationRepresentation from "@keycloak/keycloak-admin-client/lib/defs/organizationRepresentation"; +import { + Badge, + Button, + ButtonVariant, + Chip, + ChipGroup, + PageSection, + ToolbarItem, +} from "@patternfly/react-core"; +import { TableText } from "@patternfly/react-table"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Link, useNavigate } from "react-router-dom"; +import { useAdminClient } from "../admin-client"; +import { useAlerts } from "../components/alert/Alerts"; +import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog"; +import { ListEmptyState } from "../components/list-empty-state/ListEmptyState"; +import { KeycloakDataTable } from "../components/table-toolbar/KeycloakDataTable"; +import { ViewHeader } from "../components/view-header/ViewHeader"; +import { useRealm } from "../context/realm-context/RealmContext"; +import { toAddOrganization } from "./routes/AddOrganization"; +import { toEditOrganization } from "./routes/EditOrganization"; + +const OrgDetailLink = (organization: any) => { + const { t } = useTranslation(); + const { realm } = useRealm(); + return ( + + + {organization.name} + {!organization.enabled && ( + + {t("disabled")} + + )} + + + ); +}; + +const Domains = (org: OrganizationRepresentation) => { + const { t } = useTranslation(); + return ( + + {org.domains?.map((dn) => ( + + {dn.name} + + ))} + + ); +}; + +export default function OrganizationSection() { + const { adminClient } = useAdminClient(); + const { realm } = useRealm(); + const { t } = useTranslation(); + const { addAlert, addError } = useAlerts(); + const navigate = useNavigate(); + + const [key, setKey] = useState(0); + const refresh = () => setKey(key + 1); + + const [selectedOrg, setSelectedOrg] = useState(); + + async function loader(first?: number, max?: number, search?: string) { + return await adminClient.organizations.find({ first, max, search }); + } + + const [toggleDeleteDialog, DeleteConfirm] = useConfirmDialog({ + titleKey: "organizationDelete", + messageKey: "organizationDeleteConfirm", + continueButtonLabel: "delete", + continueButtonVariant: ButtonVariant.danger, + onConfirm: async () => { + try { + await adminClient.organizations.delById({ + id: selectedOrg!.id!, + }); + addAlert(t("organizationDeletedSuccess")); + refresh(); + } catch (error) { + addError("organizationDeleteError", error); + } + }, + }); + + return ( + <> + + + + + + + } + actions={[ + { + title: t("delete"), + onRowClick: (org) => { + setSelectedOrg(org); + toggleDeleteDialog(); + }, + }, + ]} + columns={[ + { + name: "name", + displayKey: "name", + cellRenderer: OrgDetailLink, + }, + { + name: "domains", + displayKey: "domains", + cellRenderer: Domains, + }, + { + name: "description", + displayKey: "description", + }, + ]} + emptyState={ + navigate(toAddOrganization({ realm }))} + /> + } + /> + + + ); +} diff --git a/js/apps/admin-ui/src/organizations/routes.ts b/js/apps/admin-ui/src/organizations/routes.ts new file mode 100644 index 000000000000..53e1b718fcb2 --- /dev/null +++ b/js/apps/admin-ui/src/organizations/routes.ts @@ -0,0 +1,12 @@ +import type { AppRouteObject } from "../routes"; +import { AddOrganizationRoute } from "./routes/AddOrganization"; +import { EditOrganizationRoute } from "./routes/EditOrganization"; +import { OrganizationsRoute } from "./routes/Organizations"; + +const routes: AppRouteObject[] = [ + OrganizationsRoute, + AddOrganizationRoute, + EditOrganizationRoute, +]; + +export default routes; diff --git a/js/apps/admin-ui/src/organizations/routes/AddOrganization.tsx b/js/apps/admin-ui/src/organizations/routes/AddOrganization.tsx new file mode 100644 index 000000000000..3a12334f77e8 --- /dev/null +++ b/js/apps/admin-ui/src/organizations/routes/AddOrganization.tsx @@ -0,0 +1,23 @@ +import { lazy } from "react"; +import type { Path } from "react-router-dom"; +import { generateEncodedPath } from "../../utils/generateEncodedPath"; +import type { AppRouteObject } from "../../routes"; + +export type AddOrganizationParams = { realm: string }; + +const NewOrganization = lazy(() => import("../NewOrganization")); + +export const AddOrganizationRoute: AppRouteObject = { + path: "/:realm/organizations/new", + element: , + breadcrumb: (t) => t("createOrganization"), + handle: { + access: "manage-users", + }, +}; + +export const toAddOrganization = ( + params: AddOrganizationParams, +): Partial => ({ + pathname: generateEncodedPath(AddOrganizationRoute.path, params), +}); diff --git a/js/apps/admin-ui/src/organizations/routes/EditOrganization.tsx b/js/apps/admin-ui/src/organizations/routes/EditOrganization.tsx new file mode 100644 index 000000000000..2387de5917e2 --- /dev/null +++ b/js/apps/admin-ui/src/organizations/routes/EditOrganization.tsx @@ -0,0 +1,33 @@ +import { lazy } from "react"; +import type { Path } from "react-router-dom"; +import { generateEncodedPath } from "../../utils/generateEncodedPath"; +import type { AppRouteObject } from "../../routes"; + +export type OrganizationTab = + | "settings" + | "attributes" + | "members" + | "identityProviders"; + +export type EditOrganizationParams = { + realm: string; + id: string; + tab: OrganizationTab; +}; + +const DetailOrganization = lazy(() => import("../DetailOrganization")); + +export const EditOrganizationRoute: AppRouteObject = { + path: "/:realm/organizations/:id/:tab", + element: , + breadcrumb: (t) => t("organizationDetails"), + handle: { + access: "manage-users", + }, +}; + +export const toEditOrganization = ( + params: EditOrganizationParams, +): Partial => ({ + pathname: generateEncodedPath(EditOrganizationRoute.path, params), +}); diff --git a/js/apps/admin-ui/src/organizations/routes/Organizations.tsx b/js/apps/admin-ui/src/organizations/routes/Organizations.tsx new file mode 100644 index 000000000000..651220ee75b3 --- /dev/null +++ b/js/apps/admin-ui/src/organizations/routes/Organizations.tsx @@ -0,0 +1,29 @@ +import { lazy } from "react"; +import type { Path } from "react-router-dom"; +import type { AppRouteObject } from "../../routes"; +import { generateEncodedPath } from "../../utils/generateEncodedPath"; + +type OrganizationsRouteParams = { + realm: string; +}; + +const OrganizationsSection = lazy(() => import("../OrganizationsSection")); + +export const OrganizationsRoute: AppRouteObject = { + path: "/:realm/organizations", + element: , + breadcrumb: (t) => t("organizationsList"), + handle: { + access: "query-groups", + }, +}; + +export const toOrganizations = ( + params: OrganizationsRouteParams, +): Partial => { + const path = OrganizationsRoute.path; + + return { + pathname: generateEncodedPath(path, params), + }; +}; diff --git a/js/apps/admin-ui/src/page/PageHandler.tsx b/js/apps/admin-ui/src/page/PageHandler.tsx index cc13e4f249d9..864c54224442 100644 --- a/js/apps/admin-ui/src/page/PageHandler.tsx +++ b/js/apps/admin-ui/src/page/PageHandler.tsx @@ -1,6 +1,5 @@ import ComponentRepresentation from "@keycloak/keycloak-admin-client/lib/defs/componentRepresentation"; import ComponentTypeRepresentation from "@keycloak/keycloak-admin-client/lib/defs/componentTypeRepresentation"; -import RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation"; import { ActionGroup, Button, Form, PageSection } from "@patternfly/react-core"; import { useState } from "react"; import { FormProvider, useForm } from "react-hook-form"; @@ -30,8 +29,7 @@ export const PageHandler = ({ const { t } = useTranslation(); const form = useForm(); - const { realm: realmName } = useRealm(); - const [realm, setRealm] = useState(); + const { realm: realmName, realmRepresentation: realm } = useRealm(); const { addAlert, addError } = useAlerts(); const [id, setId] = useState(idAttribute); const params = useParams(); @@ -39,14 +37,12 @@ export const PageHandler = ({ useFetch( async () => await Promise.all([ - adminClient.realms.findOne({ realm: realmName }), id ? adminClient.components.findOne({ id }) : Promise.resolve(), providerType === TAB_PROVIDER ? adminClient.components.find({ type: TAB_PROVIDER }) : Promise.resolve(), ]), - ([realm, data, tabs]) => { - setRealm(realm); + ([data, tabs]) => { const tab = (tabs || []).find((t) => t.providerId === providerId); form.reset(data || tab || {}); if (tab) setId(tab.id); diff --git a/js/apps/admin-ui/src/page/PageList.tsx b/js/apps/admin-ui/src/page/PageList.tsx index 634be22ecd6d..335cc6d9eaca 100644 --- a/js/apps/admin-ui/src/page/PageList.tsx +++ b/js/apps/admin-ui/src/page/PageList.tsx @@ -1,5 +1,4 @@ import ComponentRepresentation from "@keycloak/keycloak-admin-client/lib/defs/componentRepresentation"; -import type RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation"; import type { ComponentQuery } from "@keycloak/keycloak-admin-client/lib/resources/components"; import { Button, @@ -19,7 +18,6 @@ import { KeycloakDataTable } from "../components/table-toolbar/KeycloakDataTable import { ViewHeader } from "../components/view-header/ViewHeader"; import { useRealm } from "../context/realm-context/RealmContext"; import { useServerInfo } from "../context/server-info/ServerInfoProvider"; -import { useFetch } from "../utils/useFetch"; import { PageListParams, toDetailPage } from "./routes"; export const PAGE_PROVIDER = "org.keycloak.services.ui.extend.UiPageProvider"; @@ -46,20 +44,13 @@ export default function PageList() { const [key, setKey] = useState(0); const refresh = () => setKey(key + 1); - const { realm: realmName } = useRealm(); - const [realm, setRealm] = useState(); + const { realm: realmName, realmRepresentation: realm } = useRealm(); const [selectedItem, setSelectedItem] = useState(); const { componentTypes } = useServerInfo(); const pages = componentTypes?.[PAGE_PROVIDER]; const page = pages?.find((p) => p.id === providerId)!; - useFetch( - async () => adminClient.realms.findOne({ realm: realmName }), - setRealm, - [], - ); - const loader = async () => { const params: ComponentQuery = { parent: realm?.id, diff --git a/js/apps/admin-ui/src/realm-roles/RealmRoleTabs.tsx b/js/apps/admin-ui/src/realm-roles/RealmRoleTabs.tsx index c865429448c4..0e1d1dc868d9 100644 --- a/js/apps/admin-ui/src/realm-roles/RealmRoleTabs.tsx +++ b/js/apps/admin-ui/src/realm-roles/RealmRoleTabs.tsx @@ -1,4 +1,3 @@ -import type RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation"; import type RoleRepresentation from "@keycloak/keycloak-admin-client/lib/defs/roleRepresentation"; import { AlertVariant, @@ -68,7 +67,7 @@ export default function RealmRoleTabs() { const { id, clientId } = useParams(); const { pathname } = useLocation(); - const { realm: realmName } = useRealm(); + const { realm: realmName, realmRepresentation: realm } = useRealm(); const [key, setKey] = useState(0); const [attributes, setAttributes] = useState(); @@ -98,19 +97,10 @@ export default function RealmRoleTabs() { name: "composite", }); - const [realm, setRealm] = useState(); - useFetch( - async () => { - const [realm, role] = await Promise.all([ - adminClient.realms.findOne({ realm: realmName }), - adminClient.roles.findOneById({ id }), - ]); - - return { realm, role }; - }, - ({ realm, role }) => { - if (!realm || !role) { + async () => adminClient.roles.findOneById({ id }), + (role) => { + if (!role) { throw new Error(t("notFound")); } @@ -118,7 +108,6 @@ export default function RealmRoleTabs() { reset(convertedRole); setAttributes(convertedRole.attributes); - setRealm(realm); }, [key], ); diff --git a/js/apps/admin-ui/src/realm-settings/GeneralTab.tsx b/js/apps/admin-ui/src/realm-settings/GeneralTab.tsx index 80e89824bf55..2abac57ea7a4 100644 --- a/js/apps/admin-ui/src/realm-settings/GeneralTab.tsx +++ b/js/apps/admin-ui/src/realm-settings/GeneralTab.tsx @@ -40,7 +40,7 @@ import useIsFeatureEnabled, { Feature } from "../utils/useIsFeatureEnabled"; type RealmSettingsGeneralTabProps = { realm: UIRealmRepresentation; - save: (realm: UIRealmRepresentation) => void; + save: (realm: UIRealmRepresentation) => Promise; }; export const RealmSettingsGeneralTab = ({ @@ -74,7 +74,7 @@ export const RealmSettingsGeneralTab = ({ type RealmSettingsGeneralTabFormProps = { realm: UIRealmRepresentation; - save: (realm: UIRealmRepresentation) => void; + save: (realm: UIRealmRepresentation) => Promise; userProfileConfig: UserProfileConfig; }; @@ -131,17 +131,19 @@ function RealmSettingsGeneralTabForm({ useEffect(setupForm, []); - const onSubmit = handleSubmit(({ unmanagedAttributePolicy, ...data }) => { - const upConfig = { ...userProfileConfig }; + const onSubmit = handleSubmit( + async ({ unmanagedAttributePolicy, ...data }) => { + const upConfig = { ...userProfileConfig }; - if (unmanagedAttributePolicy === UnmanagedAttributePolicy.Disabled) { - delete upConfig.unmanagedAttributePolicy; - } else { - upConfig.unmanagedAttributePolicy = unmanagedAttributePolicy; - } + if (unmanagedAttributePolicy === UnmanagedAttributePolicy.Disabled) { + delete upConfig.unmanagedAttributePolicy; + } else { + upConfig.unmanagedAttributePolicy = unmanagedAttributePolicy; + } - save({ ...data, upConfig }); - }); + await save({ ...data, upConfig }); + }, + ); return ( diff --git a/js/apps/admin-ui/src/realm-settings/NewAttributeSettings.tsx b/js/apps/admin-ui/src/realm-settings/NewAttributeSettings.tsx index d5df4a649b45..f906af097c09 100644 --- a/js/apps/admin-ui/src/realm-settings/NewAttributeSettings.tsx +++ b/js/apps/admin-ui/src/realm-settings/NewAttributeSettings.tsx @@ -1,8 +1,8 @@ -import type RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation"; import type { UserProfileAttribute, UserProfileConfig, } from "@keycloak/keycloak-admin-client/lib/defs/userProfileMetadata"; +import { ScrollForm } from "@keycloak/keycloak-ui-shared"; import { AlertVariant, Button, @@ -14,14 +14,16 @@ import { useState } from "react"; import { FormProvider, useForm, useFormContext } from "react-hook-form"; import { useTranslation } from "react-i18next"; import { Link, useNavigate } from "react-router-dom"; -import { ScrollForm } from "@keycloak/keycloak-ui-shared"; import { useAdminClient } from "../admin-client"; import { useAlerts } from "../components/alert/Alerts"; import { FixedButtonsGroup } from "../components/form/FixedButtonGroup"; import { ViewHeader } from "../components/view-header/ViewHeader"; +import { useRealm } from "../context/realm-context/RealmContext"; import { convertToFormValues } from "../util"; import { useFetch } from "../utils/useFetch"; +import useLocale from "../utils/useLocale"; import { useParams } from "../utils/useParams"; +import "./realm-settings-section.css"; import type { AttributeParams } from "./routes/Attribute"; import { toUserProfile } from "./routes/UserProfile"; import { UserProfileProvider } from "./user-profile/UserProfileContext"; @@ -29,8 +31,6 @@ import { AttributeAnnotations } from "./user-profile/attribute/AttributeAnnotati import { AttributeGeneralSettings } from "./user-profile/attribute/AttributeGeneralSettings"; import { AttributePermission } from "./user-profile/attribute/AttributePermission"; import { AttributeValidations } from "./user-profile/attribute/AttributeValidations"; -import useLocale from "../utils/useLocale"; -import "./realm-settings-section.css"; type TranslationForm = { locale: string; @@ -157,6 +157,7 @@ const CreateAttributeFormContent = ({ export default function NewAttributeSettings() { const { adminClient } = useAdminClient(); const { realm: realmName, attributeName } = useParams(); + const { realmRepresentation: realm } = useRealm(); const form = useForm(); const { t } = useTranslation(); const combinedLocales = useLocale(); @@ -169,18 +170,6 @@ export default function NewAttributeSettings() { translations: [], }); const [generatedDisplayName, setGeneratedDisplayName] = useState(""); - const [realm, setRealm] = useState(); - - useFetch( - () => adminClient.realms.findOne({ realm: realmName }), - (realm) => { - if (!realm) { - throw new Error(t("notFound")); - } - setRealm(realm); - }, - [], - ); useFetch( async () => { diff --git a/js/apps/admin-ui/src/realm-settings/RealmSettingsSection.tsx b/js/apps/admin-ui/src/realm-settings/RealmSettingsSection.tsx index 65a99ce2f5a1..6e78fbb343e8 100644 --- a/js/apps/admin-ui/src/realm-settings/RealmSettingsSection.tsx +++ b/js/apps/admin-ui/src/realm-settings/RealmSettingsSection.tsx @@ -1,30 +1,5 @@ -import type RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation"; -import { useState } from "react"; -import { useAdminClient } from "../admin-client"; -import { KeycloakSpinner } from "../components/keycloak-spinner/KeycloakSpinner"; -import { useFetch } from "../utils/useFetch"; -import { useParams } from "../utils/useParams"; import { RealmSettingsTabs } from "./RealmSettingsTabs"; -import type { RealmSettingsParams } from "./routes/RealmSettings"; export default function RealmSettingsSection() { - const { adminClient } = useAdminClient(); - - const { realm: realmName } = useParams(); - const [realm, setRealm] = useState(); - const [key, setKey] = useState(0); - - const refresh = () => { - setKey(key + 1); - setRealm(undefined); - }; - - useFetch(() => adminClient.realms.findOne({ realm: realmName }), setRealm, [ - key, - ]); - - if (!realm) { - return ; - } - return ; + return ; } diff --git a/js/apps/admin-ui/src/realm-settings/RealmSettingsTabs.tsx b/js/apps/admin-ui/src/realm-settings/RealmSettingsTabs.tsx index 1d05a2bc1653..73ba7d503096 100644 --- a/js/apps/admin-ui/src/realm-settings/RealmSettingsTabs.tsx +++ b/js/apps/admin-ui/src/realm-settings/RealmSettingsTabs.tsx @@ -1,7 +1,7 @@ import { fetchWithError } from "@keycloak/keycloak-admin-client"; +import type RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation"; import { UserProfileConfig } from "@keycloak/keycloak-admin-client/lib/defs/userProfileMetadata"; import { AdminEnvironment, useEnvironment } from "@keycloak/keycloak-ui-shared"; -import type RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation"; import { AlertVariant, ButtonVariant, @@ -18,6 +18,7 @@ import { useEffect, useState } from "react"; import { Controller, useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; +import { useAdminClient } from "../admin-client"; import { useAlerts } from "../components/alert/Alerts"; import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog"; import type { KeyValueType } from "../components/key-value-form/key-value-convert"; @@ -27,6 +28,7 @@ import { } from "../components/routable-tabs/RoutableTabs"; import { ViewHeader } from "../components/view-header/ViewHeader"; import { useRealms } from "../context/RealmsContext"; +import { useAccess } from "../context/access/Access"; import { useRealm } from "../context/realm-context/RealmContext"; import { toDashboard } from "../dashboard/routes/Dashboard"; import helpUrls from "../help-urls"; @@ -34,9 +36,9 @@ import { convertFormValuesToObject, convertToFormValues } from "../util"; import { getAuthorizationHeaders } from "../utils/getAuthorizationHeaders"; import { joinPath } from "../utils/joinPath"; import useIsFeatureEnabled, { Feature } from "../utils/useIsFeatureEnabled"; +import useLocale from "../utils/useLocale"; import { RealmSettingsEmailTab } from "./EmailTab"; import { RealmSettingsGeneralTab } from "./GeneralTab"; -import { LocalizationTab } from "./localization/LocalizationTab"; import { RealmSettingsLoginTab } from "./LoginTab"; import { PartialExportDialog } from "./PartialExport"; import { PartialImportDialog } from "./PartialImport"; @@ -48,13 +50,11 @@ import { RealmSettingsTokensTab } from "./TokensTab"; import { UserRegistration } from "./UserRegistration"; import { EventsTab } from "./event-config/EventsTab"; import { KeysTab } from "./keys/KeysTab"; +import { LocalizationTab } from "./localization/LocalizationTab"; import { ClientPoliciesTab, toClientPolicies } from "./routes/ClientPolicies"; import { RealmSettingsTab, toRealmSettings } from "./routes/RealmSettings"; import { SecurityDefenses } from "./security-defences/SecurityDefenses"; import { UserProfileTab } from "./user-profile/UserProfileTab"; -import useLocale from "../utils/useLocale"; -import { useAdminClient } from "../admin-client"; -import { useAccess } from "../context/access/Access"; export interface UIRealmRepresentation extends RealmRepresentation { upConfig?: UserProfileConfig; @@ -174,20 +174,11 @@ const RealmSettingsHeader = ({ ); }; -type RealmSettingsTabsProps = { - realm: UIRealmRepresentation; - refresh: () => void; - tableData?: Record[]; -}; - -export const RealmSettingsTabs = ({ - realm, - refresh, -}: RealmSettingsTabsProps) => { +export const RealmSettingsTabs = () => { const { adminClient } = useAdminClient(); const { t } = useTranslation(); const { addAlert, addError } = useAlerts(); - const { realm: realmName } = useRealm(); + const { realm: realmName, realmRepresentation: realm, refresh } = useRealm(); const { refresh: refreshRealms } = useRealms(); const combinedLocales = useLocale(); const navigate = useNavigate(); @@ -203,7 +194,7 @@ export const RealmSettingsTabs = ({ setKey(key + 1); }; - const setupForm = (r: RealmRepresentation = realm) => { + const setupForm = (r: RealmRepresentation = realm!) => { convertToFormValues(r, setValue); }; @@ -278,7 +269,7 @@ export const RealmSettingsTabs = ({ addError("realmSaveError", error); } - const isRealmRenamed = realmName !== (r.realm || realm.realm); + const isRealmRenamed = realmName !== (r.realm || realm?.realm); if (isRealmRenamed) { await refreshRealms(); navigate(toRealmSettings({ realm: r.realm!, tab: "general" })); @@ -345,28 +336,28 @@ export const RealmSettingsTabs = ({ data-testid="rs-general-tab" {...generalTab} > - + {t("login")}} data-testid="rs-login-tab" {...loginTab} > - + {t("email")}} data-testid="rs-email-tab" {...emailTab} > - + {t("themes")}} data-testid="rs-themes-tab" {...themesTab} > - + {t("keys")}} @@ -380,7 +371,7 @@ export const RealmSettingsTabs = ({ data-testid="rs-realm-events-tab" {...eventsTab} > - + {t("localization")}} @@ -390,7 +381,7 @@ export const RealmSettingsTabs = ({ @@ -399,21 +390,21 @@ export const RealmSettingsTabs = ({ data-testid="rs-security-defenses-tab" {...securityDefensesTab} > - + {t("sessions")}} data-testid="rs-sessions-tab" {...sessionsTab} > - + {t("tokens")}} data-testid="rs-tokens-tab" {...tokensTab} > - + {isFeatureEnabled(Feature.ClientPolicies) && ( { const { adminClient } = useAdminClient(); const { t } = useTranslation(); - const [realm, setRealm] = useState(); const [activeTab, setActiveTab] = useState(10); + const { realmRepresentation: realm } = useRealm(); const [key, setKey] = useState(0); const { addAlert, addError } = useAlerts(); const { realm: realmName } = useRealm(); - useFetch( - () => adminClient.realms.findOne({ realm: realmName }), - setRealm, - [], - ); - - if (!realm) { - return ; - } - const addComposites = async (composites: RoleRepresentation[]) => { const compositeArray = composites; try { await adminClient.roles.createComposite( - { roleId: realm.defaultRole!.id!, realm: realmName }, + { roleId: realm?.defaultRole!.id!, realm: realmName }, compositeArray, ); setKey(key + 1); @@ -60,8 +47,8 @@ export const UserRegistration = () => { data-testid="default-roles-tab" > addComposites(rows.map((r) => r.role))} diff --git a/js/apps/admin-ui/src/realm-settings/user-profile/AttributesGroupForm.tsx b/js/apps/admin-ui/src/realm-settings/user-profile/AttributesGroupForm.tsx index 9d1b299242ce..14aaf3e87f20 100644 --- a/js/apps/admin-ui/src/realm-settings/user-profile/AttributesGroupForm.tsx +++ b/js/apps/admin-ui/src/realm-settings/user-profile/AttributesGroupForm.tsx @@ -1,5 +1,5 @@ import type { UserProfileGroup } from "@keycloak/keycloak-admin-client/lib/defs/userProfileMetadata"; -import type RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation"; +import { HelpItem, TextControl } from "@keycloak/keycloak-ui-shared"; import { ActionGroup, Alert, @@ -12,6 +12,7 @@ import { TextContent, TextInput, } from "@patternfly/react-core"; +import { GlobeRouteIcon } from "@patternfly/react-icons"; import { useEffect, useMemo, useState } from "react"; import { FormProvider, @@ -21,26 +22,24 @@ import { } from "react-hook-form"; import { useTranslation } from "react-i18next"; import { Link, useNavigate, useParams } from "react-router-dom"; -import { HelpItem, TextControl } from "@keycloak/keycloak-ui-shared"; +import { useAdminClient } from "../../admin-client"; import { useAlerts } from "../../components/alert/Alerts"; import { FormAccess } from "../../components/form/FormAccess"; import { KeyValueInput } from "../../components/key-value-form/KeyValueInput"; import type { KeyValueType } from "../../components/key-value-form/key-value-convert"; import { ViewHeader } from "../../components/view-header/ViewHeader"; import { useRealm } from "../../context/realm-context/RealmContext"; +import { useFetch } from "../../utils/useFetch"; +import useLocale from "../../utils/useLocale"; +import useToggle from "../../utils/useToggle"; +import "../realm-settings-section.css"; import type { EditAttributesGroupParams } from "../routes/EditAttributesGroup"; import { toUserProfile } from "../routes/UserProfile"; import { useUserProfile } from "./UserProfileContext"; -import { useFetch } from "../../utils/useFetch"; -import { GlobeRouteIcon } from "@patternfly/react-icons"; -import useToggle from "../../utils/useToggle"; -import useLocale from "../../utils/useLocale"; import { AddTranslationsDialog, TranslationsType, } from "./attribute/AddTranslationsDialog"; -import "../realm-settings-section.css"; -import { useAdminClient } from "../../admin-client"; function parseAnnotations(input: Record): KeyValueType[] { return Object.entries(input).reduce((p, [key, value]) => { @@ -89,13 +88,12 @@ const defaultValues: FormFields = { export default function AttributesGroupForm() { const { adminClient } = useAdminClient(); const { t } = useTranslation(); - const { realm: realmName } = useRealm(); + const { realm: realmName, realmRepresentation: realm } = useRealm(); const { config, save } = useUserProfile(); const navigate = useNavigate(); const combinedLocales = useLocale(); const params = useParams(); const form = useForm({ defaultValues }); - const [realm, setRealm] = useState(); const { addError } = useAlerts(); const editMode = params.name ? true : false; const [newAttributesGroupName, setNewAttributesGroupName] = useState(""); @@ -156,17 +154,6 @@ export default function AttributesGroupForm() { generatedAttributesGroupDisplayDescription, ]); - useFetch( - () => adminClient.realms.findOne({ realm: realmName }), - (realm) => { - if (!realm) { - throw new Error(t("notFound")); - } - setRealm(realm); - }, - [], - ); - useFetch( async () => { const translationsToSaveDisplayHeader: Translations[] = []; diff --git a/js/apps/admin-ui/src/realm-settings/user-profile/attribute/AddTranslationsDialog.tsx b/js/apps/admin-ui/src/realm-settings/user-profile/attribute/AddTranslationsDialog.tsx index ae4ebcd09d0e..f4808fb01f14 100644 --- a/js/apps/admin-ui/src/realm-settings/user-profile/attribute/AddTranslationsDialog.tsx +++ b/js/apps/admin-ui/src/realm-settings/user-profile/attribute/AddTranslationsDialog.tsx @@ -1,4 +1,4 @@ -import type RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation"; +import { TextControl } from "@keycloak/keycloak-ui-shared"; import { Button, Flex, @@ -12,20 +12,19 @@ import { TextContent, TextVariants, } from "@patternfly/react-core"; -import { Table, Tbody, Td, Th, Thead, Tr } from "@patternfly/react-table"; import { SearchIcon } from "@patternfly/react-icons"; +import { Table, Tbody, Td, Th, Thead, Tr } from "@patternfly/react-table"; import { useEffect, useMemo, useState } from "react"; +import { FormProvider, useForm, useWatch } from "react-hook-form"; import { useTranslation } from "react-i18next"; import { useAdminClient } from "../../../admin-client"; import { ListEmptyState } from "../../../components/list-empty-state/ListEmptyState"; import { PaginatingTableToolbar } from "../../../components/table-toolbar/PaginatingTableToolbar"; -import { FormProvider, useForm, useWatch } from "react-hook-form"; import { useRealm } from "../../../context/realm-context/RealmContext"; import { useWhoAmI } from "../../../context/whoami/WhoAmI"; -import { useFetch } from "../../../utils/useFetch"; import { localeToDisplayName } from "../../../util"; +import { useFetch } from "../../../utils/useFetch"; import useLocale from "../../../utils/useLocale"; -import { TextControl } from "@keycloak/keycloak-ui-shared"; export type TranslationsType = | "displayName" @@ -61,9 +60,8 @@ export const AddTranslationsDialog = ({ }: AddTranslationsDialogProps) => { const { adminClient } = useAdminClient(); const { t } = useTranslation(); - const { realm: realmName } = useRealm(); + const { realm: realmName, realmRepresentation: realm } = useRealm(); const combinedLocales = useLocale(); - const [realm, setRealm] = useState(); const { whoAmI } = useWhoAmI(); const [max, setMax] = useState(10); const [first, setFirst] = useState(0); @@ -86,17 +84,6 @@ export const AddTranslationsDialog = ({ formState: { isValid }, } = form; - useFetch( - () => adminClient.realms.findOne({ realm: realmName }), - (realm) => { - if (!realm) { - throw new Error(t("notFound")); - } - setRealm(realm); - }, - [], - ); - const defaultLocales = useMemo(() => { return realm?.defaultLocale!.length ? [realm.defaultLocale] : []; }, [realm]); diff --git a/js/apps/admin-ui/src/realm-settings/user-profile/attribute/AttributeGeneralSettings.tsx b/js/apps/admin-ui/src/realm-settings/user-profile/attribute/AttributeGeneralSettings.tsx index e0b476bf22b3..ac2d9a94f446 100644 --- a/js/apps/admin-ui/src/realm-settings/user-profile/attribute/AttributeGeneralSettings.tsx +++ b/js/apps/admin-ui/src/realm-settings/user-profile/attribute/AttributeGeneralSettings.tsx @@ -1,6 +1,6 @@ import type ClientScopeRepresentation from "@keycloak/keycloak-admin-client/lib/defs/clientScopeRepresentation"; -import type RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation"; import type { UserProfileConfig } from "@keycloak/keycloak-admin-client/lib/defs/userProfileMetadata"; +import { FormErrorText, HelpItem } from "@keycloak/keycloak-ui-shared"; import { Alert, Button, @@ -22,7 +22,7 @@ import { isEqual } from "lodash-es"; import { useEffect, useState } from "react"; import { Controller, useFormContext, useWatch } from "react-hook-form"; import { useTranslation } from "react-i18next"; -import { FormErrorText, HelpItem } from "@keycloak/keycloak-ui-shared"; +import { useAdminClient } from "../../../admin-client"; import { FormAccess } from "../../../components/form/FormAccess"; import { KeycloakSpinner } from "../../../components/keycloak-spinner/KeycloakSpinner"; import { useRealm } from "../../../context/realm-context/RealmContext"; @@ -30,13 +30,12 @@ import { useFetch } from "../../../utils/useFetch"; import { useParams } from "../../../utils/useParams"; import useToggle from "../../../utils/useToggle"; import { USERNAME_EMAIL } from "../../NewAttributeSettings"; +import "../../realm-settings-section.css"; import { AttributeParams } from "../../routes/Attribute"; import { AddTranslationsDialog, TranslationsType, } from "./AddTranslationsDialog"; -import { useAdminClient } from "../../../admin-client"; -import "../../realm-settings-section.css"; const REQUIRED_FOR = [ { label: "requiredForLabel.both", value: ["admin", "user"] }, @@ -65,7 +64,7 @@ export const AttributeGeneralSettings = ({ }: AttributeGeneralSettingsProps) => { const { adminClient } = useAdminClient(); const { t } = useTranslation(); - const { realm: realmName } = useRealm(); + const { realmRepresentation: realm } = useRealm(); const form = useFormContext(); const [clientScopes, setClientScopes] = useState(); @@ -77,7 +76,6 @@ export const AttributeGeneralSettings = ({ const [addTranslationsModalOpen, toggleModal] = useToggle(); const { attributeName } = useParams(); const editMode = attributeName ? true : false; - const [realm, setRealm] = useState(); const [newAttributeName, setNewAttributeName] = useState(""); const [generatedDisplayName, setGeneratedDisplayName] = useState(""); const [type, setType] = useState(); @@ -122,17 +120,6 @@ export const AttributeGeneralSettings = ({ const displayNamePatternMatch = displayNameRegex.test(attributeDisplayName); - useFetch( - () => adminClient.realms.findOne({ realm: realmName }), - (realm) => { - if (!realm) { - throw new Error(t("notFound")); - } - setRealm(realm); - }, - [], - ); - useFetch(() => adminClient.clientScopes.find(), setClientScopes, []); useFetch(() => adminClient.users.getProfile(), setConfig, []); diff --git a/js/apps/admin-ui/src/routes.tsx b/js/apps/admin-ui/src/routes.tsx index 94c251627126..74d4d6f71251 100644 --- a/js/apps/admin-ui/src/routes.tsx +++ b/js/apps/admin-ui/src/routes.tsx @@ -11,6 +11,7 @@ import dashboardRoutes from "./dashboard/routes"; import eventRoutes from "./events/routes"; import groupsRoutes from "./groups/routes"; import identityProviders from "./identity-providers/routes"; +import organizationRoutes from "./organizations/routes"; import pageRoutes from "./page/routes"; import realmRoleRoutes from "./realm-roles/routes"; import realmSettingRoutes from "./realm-settings/routes"; @@ -43,6 +44,7 @@ export const routes: AppRouteObject[] = [ ...clientScopesRoutes, ...eventRoutes, ...identityProviders, + ...organizationRoutes, ...realmRoleRoutes, ...realmRoutes, ...realmSettingRoutes, diff --git a/js/apps/admin-ui/src/sessions/RevocationModal.tsx b/js/apps/admin-ui/src/sessions/RevocationModal.tsx index 1f63677ce99a..c534e5b3b23e 100644 --- a/js/apps/admin-ui/src/sessions/RevocationModal.tsx +++ b/js/apps/admin-ui/src/sessions/RevocationModal.tsx @@ -1,5 +1,4 @@ import type GlobalRequestResult from "@keycloak/keycloak-admin-client/lib/defs/globalRequestResult"; -import type RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation"; import { AlertVariant, Button, @@ -11,13 +10,11 @@ import { TextContent, TextInput, } from "@patternfly/react-core"; -import { useState } from "react"; import { useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; import { useAdminClient } from "../admin-client"; import { useAlerts } from "../components/alert/Alerts"; import { useRealm } from "../context/realm-context/RealmContext"; -import { useFetch } from "../utils/useFetch"; type RevocationModalProps = { handleModalToggle: () => void; @@ -33,23 +30,8 @@ export const RevocationModal = ({ const { t } = useTranslation(); const { addAlert } = useAlerts(); - const { realm: realmName } = useRealm(); + const { realm: realmName, realmRepresentation: realm, refresh } = useRealm(); const { register, handleSubmit } = useForm(); - const [realm, setRealm] = useState(); - - const [key, setKey] = useState(0); - - const refresh = () => { - setKey(new Date().getTime()); - }; - - useFetch( - () => adminClient.realms.findOne({ realm: realmName }), - (realm) => { - setRealm(realm); - }, - [key], - ); const parseResult = (result: GlobalRequestResult, prefixKey: string) => { const successCount = result.successRequests?.length || 0; diff --git a/js/apps/admin-ui/src/user-federation/UserFederationSection.tsx b/js/apps/admin-ui/src/user-federation/UserFederationSection.tsx index ad9637a98758..101455474793 100644 --- a/js/apps/admin-ui/src/user-federation/UserFederationSection.tsx +++ b/js/apps/admin-ui/src/user-federation/UserFederationSection.tsx @@ -44,7 +44,7 @@ export default function UserFederationSection() { useState(); const { addAlert, addError } = useAlerts(); const { t } = useTranslation(); - const { realm } = useRealm(); + const { realm, realmRepresentation } = useRealm(); const [key, setKey] = useState(0); const refresh = () => setKey(new Date().getTime()); @@ -59,9 +59,8 @@ export default function UserFederationSection() { useFetch( async () => { - const realmModel = await adminClient.realms.findOne({ realm }); const testParams: { [name: string]: string | number } = { - parentId: realmModel!.id!, + parentId: realmRepresentation!.id!, type: "org.keycloak.storage.UserStorageProvider", }; return adminClient.components.find(testParams); diff --git a/js/apps/admin-ui/src/user-federation/custom/CustomProviderSettings.tsx b/js/apps/admin-ui/src/user-federation/custom/CustomProviderSettings.tsx index 4eb14496899b..c192a761b0a2 100644 --- a/js/apps/admin-ui/src/user-federation/custom/CustomProviderSettings.tsx +++ b/js/apps/admin-ui/src/user-federation/custom/CustomProviderSettings.tsx @@ -1,15 +1,14 @@ import type ComponentRepresentation from "@keycloak/keycloak-admin-client/lib/defs/componentRepresentation"; +import { TextControl } from "@keycloak/keycloak-ui-shared"; import { ActionGroup, AlertVariant, Button, PageSection, } from "@patternfly/react-core"; -import { useState } from "react"; import { FormProvider, useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; import { Link, useNavigate } from "react-router-dom"; -import { TextControl } from "@keycloak/keycloak-ui-shared"; import { useAdminClient } from "../../admin-client"; import { useAlerts } from "../../components/alert/Alerts"; import { DynamicComponents } from "../../components/dynamic/DynamicComponents"; @@ -44,8 +43,7 @@ export default function CustomProviderSettings() { } = form; const { addAlert, addError } = useAlerts(); - const { realm: realmName } = useRealm(); - const [parentId, setParentId] = useState(""); + const { realm: realmName, realmRepresentation: realm } = useRealm(); const provider = ( useServerInfo().componentTypes?.[ @@ -70,15 +68,6 @@ export default function CustomProviderSettings() { [], ); - useFetch( - () => - adminClient.realms.findOne({ - realm: realmName, - }), - (realm) => setParentId(realm?.id!), - [], - ); - const save = async (component: ComponentRepresentation) => { const saveComponent = convertFormValuesToObject({ ...component, @@ -90,7 +79,7 @@ export default function CustomProviderSettings() { ), providerId, providerType: "org.keycloak.storage.UserStorageProvider", - parentId, + parentId: realm?.id, }); try { diff --git a/js/apps/admin-ui/src/user-federation/kerberos/KerberosSettingsRequired.tsx b/js/apps/admin-ui/src/user-federation/kerberos/KerberosSettingsRequired.tsx index 39981b1874c5..c5520b63d56e 100644 --- a/js/apps/admin-ui/src/user-federation/kerberos/KerberosSettingsRequired.tsx +++ b/js/apps/admin-ui/src/user-federation/kerberos/KerberosSettingsRequired.tsx @@ -1,3 +1,4 @@ +import { HelpItem, TextControl } from "@keycloak/keycloak-ui-shared"; import { FormGroup, Switch } from "@patternfly/react-core"; import { Select, @@ -5,7 +6,7 @@ import { SelectVariant, } from "@patternfly/react-core/deprecated"; import { isEqual } from "lodash-es"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { Controller, FormProvider, @@ -13,12 +14,9 @@ import { useWatch, } from "react-hook-form"; import { useTranslation } from "react-i18next"; -import { HelpItem, TextControl } from "@keycloak/keycloak-ui-shared"; -import { useAdminClient } from "../../admin-client"; import { FormAccess } from "../../components/form/FormAccess"; import { WizardSectionHeader } from "../../components/wizard-section-header/WizardSectionHeader"; import { useRealm } from "../../context/realm-context/RealmContext"; -import { useFetch } from "../../utils/useFetch"; export type KerberosSettingsRequiredProps = { form: UseFormReturn; @@ -31,10 +29,8 @@ export const KerberosSettingsRequired = ({ showSectionHeading = false, showSectionDescription = false, }: KerberosSettingsRequiredProps) => { - const { adminClient } = useAdminClient(); - const { t } = useTranslation(); - const { realm } = useRealm(); + const { realm, realmRepresentation } = useRealm(); const [isEditModeDropdownOpen, setIsEditModeDropdownOpen] = useState(false); @@ -43,11 +39,7 @@ export const KerberosSettingsRequired = ({ name: "config.allowPasswordAuthentication", }); - useFetch( - () => adminClient.realms.findOne({ realm }), - (result) => form.setValue("parentId", result!.id), - [], - ); + useEffect(() => form.setValue("parentId", realmRepresentation?.id), []); return ( diff --git a/js/apps/admin-ui/src/user-federation/ldap/LdapSettingsGeneral.tsx b/js/apps/admin-ui/src/user-federation/ldap/LdapSettingsGeneral.tsx index c755cdc2e676..c931c28827a9 100644 --- a/js/apps/admin-ui/src/user-federation/ldap/LdapSettingsGeneral.tsx +++ b/js/apps/admin-ui/src/user-federation/ldap/LdapSettingsGeneral.tsx @@ -1,19 +1,17 @@ import ComponentRepresentation from "@keycloak/keycloak-admin-client/lib/defs/componentRepresentation"; +import { HelpItem, TextControl } from "@keycloak/keycloak-ui-shared"; import { FormGroup } from "@patternfly/react-core"; import { Select, SelectOption, SelectVariant, } from "@patternfly/react-core/deprecated"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { Controller, FormProvider, UseFormReturn } from "react-hook-form"; import { useTranslation } from "react-i18next"; -import { HelpItem, TextControl } from "@keycloak/keycloak-ui-shared"; -import { useAdminClient } from "../../admin-client"; import { FormAccess } from "../../components/form/FormAccess"; import { WizardSectionHeader } from "../../components/wizard-section-header/WizardSectionHeader"; import { useRealm } from "../../context/realm-context/RealmContext"; -import { useFetch } from "../../utils/useFetch"; export type LdapSettingsGeneralProps = { form: UseFormReturn; @@ -28,16 +26,10 @@ export const LdapSettingsGeneral = ({ showSectionDescription = false, vendorEdit = false, }: LdapSettingsGeneralProps) => { - const { adminClient } = useAdminClient(); - const { t } = useTranslation(); - const { realm } = useRealm(); + const { realm, realmRepresentation } = useRealm(); - useFetch( - () => adminClient.realms.findOne({ realm }), - (result) => form.setValue("parentId", result!.id), - [], - ); + useEffect(() => form.setValue("parentId", realmRepresentation?.id), []); const [isVendorDropdownOpen, setIsVendorDropdownOpen] = useState(false); const setVendorDefaultValues = () => { diff --git a/js/apps/admin-ui/src/user/CreateUser.tsx b/js/apps/admin-ui/src/user/CreateUser.tsx index afa589bca900..53378eb8cf3a 100644 --- a/js/apps/admin-ui/src/user/CreateUser.tsx +++ b/js/apps/admin-ui/src/user/CreateUser.tsx @@ -1,16 +1,15 @@ import type GroupRepresentation from "@keycloak/keycloak-admin-client/lib/defs/groupRepresentation"; -import RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation"; import type { UserProfileMetadata } from "@keycloak/keycloak-admin-client/lib/defs/userProfileMetadata"; +import { + isUserProfileError, + setUserProfileServerError, +} from "@keycloak/keycloak-ui-shared"; import { AlertVariant, PageSection } from "@patternfly/react-core"; import { TFunction } from "i18next"; import { useState } from "react"; import { useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; -import { - isUserProfileError, - setUserProfileServerError, -} from "@keycloak/keycloak-ui-shared"; import { useAdminClient } from "../admin-client"; import { useAlerts } from "../components/alert/Alerts"; import { KeycloakSpinner } from "../components/keycloak-spinner/KeycloakSpinner"; @@ -29,25 +28,19 @@ export default function CreateUser() { const { t } = useTranslation(); const { addAlert, addError } = useAlerts(); const navigate = useNavigate(); - const { realm: realmName } = useRealm(); + const { realm: realmName, realmRepresentation: realm } = useRealm(); const form = useForm({ mode: "onChange" }); const [addedGroups, setAddedGroups] = useState([]); - const [realm, setRealm] = useState(); const [userProfileMetadata, setUserProfileMetadata] = useState(); useFetch( - () => - Promise.all([ - adminClient.realms.findOne({ realm: realmName }), - adminClient.users.getProfileMetadata({ realm: realmName }), - ]), - ([realm, userProfileMetadata]) => { + () => adminClient.users.getProfileMetadata({ realm: realmName }), + (userProfileMetadata) => { if (!realm) { throw new Error(t("notFound")); } - setRealm(realm); form.setValue("attributes.locale", realm.defaultLocale || ""); setUserProfileMetadata(userProfileMetadata); }, diff --git a/js/apps/admin-ui/src/user/EditUser.tsx b/js/apps/admin-ui/src/user/EditUser.tsx index 091ea1fc3251..30de54b9539c 100644 --- a/js/apps/admin-ui/src/user/EditUser.tsx +++ b/js/apps/admin-ui/src/user/EditUser.tsx @@ -1,8 +1,11 @@ -import RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation"; import type { UserProfileConfig, UserProfileMetadata, } from "@keycloak/keycloak-admin-client/lib/defs/userProfileMetadata"; +import { + isUserProfileError, + setUserProfileServerError, +} from "@keycloak/keycloak-ui-shared"; import { AlertVariant, ButtonVariant, @@ -19,10 +22,6 @@ import { useState } from "react"; import { FormProvider, useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; -import { - isUserProfileError, - setUserProfileServerError, -} from "@keycloak/keycloak-ui-shared"; import { useAdminClient } from "../admin-client"; import { useAlerts } from "../components/alert/Alerts"; import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog"; @@ -67,7 +66,7 @@ export default function EditUser() { const navigate = useNavigate(); const { hasAccess } = useAccess(); const { id } = useParams(); - const { realm: realmName } = useRealm(); + const { realm: realmName, realmRepresentation: realm } = useRealm(); // Validation of form fields is performed on server, thus we need to clear all errors before submit const clearAllErrorsBeforeSubmit = async (values: UserFormFields) => ({ values, @@ -77,7 +76,6 @@ export default function EditUser() { mode: "onChange", resolver: clearAllErrorsBeforeSubmit, }); - const [realm, setRealm] = useState(); const [user, setUser] = useState(); const [bruteForced, setBruteForced] = useState(); const [isUnmanagedAttributesEnabled, setUnmanagedAttributesEnabled] = @@ -110,7 +108,6 @@ export default function EditUser() { useFetch( async () => Promise.all([ - adminClient.realms.findOne({ realm: realmName }), adminClient.users.findOne({ id: id!, userProfileMetadata: true, @@ -119,7 +116,7 @@ export default function EditUser() { adminClient.users.getUnmanagedAttributes({ id: id! }), adminClient.users.getProfile({ realm: realmName }), ]), - ([realm, userData, attackDetection, unmanagedAttributes, upConfig]) => { + ([userData, attackDetection, unmanagedAttributes, upConfig]) => { if (!userData || !realm || !attackDetection) { throw new Error(t("notFound")); } @@ -136,7 +133,6 @@ export default function EditUser() { setUnmanagedAttributesEnabled(true); } - setRealm(realm); setUser(user); setUpConfig(upConfig); @@ -247,7 +243,7 @@ export default function EditUser() { }, }); - if (!realm || !user || !bruteForced) { + if (!user || !bruteForced) { return ; } @@ -318,7 +314,7 @@ export default function EditUser() { { - return (await adminClient.realms.findOne({ realm }))!.identityProviders; + const getAvailableIdPs = () => { + return realmRepresentation?.identityProviders; }; const linkedIdPsLoader = async () => { @@ -87,7 +87,7 @@ export const UserIdentityProviderLinks = ({ (x) => x.identityProvider, ); - return (await getAvailableIdPs())?.filter( + return getAvailableIdPs()?.filter( (item) => !linkedNames.includes(item.alias), )!; }; diff --git a/js/apps/admin-ui/src/utils/useLocale.ts b/js/apps/admin-ui/src/utils/useLocale.ts index e0fde15eb19d..7991e9a8e4da 100644 --- a/js/apps/admin-ui/src/utils/useLocale.ts +++ b/js/apps/admin-ui/src/utils/useLocale.ts @@ -1,27 +1,10 @@ -import type RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation"; -import { useMemo, useState } from "react"; -import { DEFAULT_LOCALE } from "../i18n/i18n"; -import { useFetch } from "./useFetch"; +import { useMemo } from "react"; import { useRealm } from "../context/realm-context/RealmContext"; -import { useTranslation } from "react-i18next"; -import { useAdminClient } from "../admin-client"; +import { DEFAULT_LOCALE } from "../i18n/i18n"; export default function useLocale() { - const { adminClient } = useAdminClient(); - const { t } = useTranslation(); - const { realm: realmName } = useRealm(); - const [realm, setRealm] = useState(); + const { realmRepresentation: realm } = useRealm(); - useFetch( - () => adminClient.realms.findOne({ realm: realmName }), - (realm) => { - if (!realm) { - throw new Error(t("notFound")); - } - setRealm(realm); - }, - [], - ); const defaultSupportedLocales = useMemo(() => { return realm?.supportedLocales?.length ? realm.supportedLocales diff --git a/js/libs/keycloak-admin-client/src/defs/organizationRepresentation.ts b/js/libs/keycloak-admin-client/src/defs/organizationRepresentation.ts index 0639a501621c..eac1b6f87923 100644 --- a/js/libs/keycloak-admin-client/src/defs/organizationRepresentation.ts +++ b/js/libs/keycloak-admin-client/src/defs/organizationRepresentation.ts @@ -5,6 +5,6 @@ export default interface OrganizationRepresentation { name?: string; description?: string; enabled?: boolean; - attributes?: { [index: string]: string[] }; + attributes?: Record; domains?: OrganizationDomainRepresentation[]; } diff --git a/js/libs/keycloak-admin-client/src/resources/agent.ts b/js/libs/keycloak-admin-client/src/resources/agent.ts index 6f439c7ec8ec..66add467a056 100644 --- a/js/libs/keycloak-admin-client/src/resources/agent.ts +++ b/js/libs/keycloak-admin-client/src/resources/agent.ts @@ -225,9 +225,10 @@ export class Agent { requestOptions.body = payload; } else { // Otherwise assume it's JSON and stringify it. - requestOptions.body = JSON.stringify( - payloadKey ? payload[payloadKey] : payload, - ); + requestOptions.body = + payloadKey && typeof payload[payloadKey] === "string" + ? payload[payloadKey] + : JSON.stringify(payloadKey ? payload[payloadKey] : payload); } if (!requestHeaders.has("content-type") && !(payload instanceof FormData)) { diff --git a/js/libs/keycloak-admin-client/src/resources/organizations.ts b/js/libs/keycloak-admin-client/src/resources/organizations.ts index 9f706b47d960..8ca0c9fce9d3 100644 --- a/js/libs/keycloak-admin-client/src/resources/organizations.ts +++ b/js/libs/keycloak-admin-client/src/resources/organizations.ts @@ -1,16 +1,23 @@ -import Resource from "./resource.js"; -import type OrganizationRepresentation from "../defs/organizationRepresentation.js"; - import type { KeycloakAdminClient } from "../client.js"; +import IdentityProviderRepresentation from "../defs/identityProviderRepresentation.js"; +import type OrganizationRepresentation from "../defs/organizationRepresentation.js"; +import UserRepresentation from "../defs/userRepresentation.js"; +import Resource from "./resource.js"; -export interface OrganizationQuery { +interface PaginatedQuery { first?: number; // The position of the first result to be processed (pagination offset) max?: number; // The maximum number of results to be returned - defaults to 10 - search?: string; // A String representing either an organization name or domain + search?: string; +} +export interface OrganizationQuery extends PaginatedQuery { q?: string; // A query to search for custom attributes, in the format 'key1:value2 key2:value2' exact?: boolean; // Boolean which defines whether the param 'search' must match exactly or not } +interface MemberQuery extends PaginatedQuery { + orgId: string; //Id of the organization to get the members of +} + export class Organizations extends Resource<{ realm?: string }> { /** * Organizations @@ -18,7 +25,7 @@ export class Organizations extends Resource<{ realm?: string }> { constructor(client: KeycloakAdminClient) { super(client, { - path: "/admin/realms/{realm}", + path: "/admin/realms/{realm}/organizations", getUrlParams: () => ({ realm: client.realmName, }), @@ -31,18 +38,26 @@ export class Organizations extends Resource<{ realm?: string }> { OrganizationRepresentation[] >({ method: "GET", - path: "/organizations", + path: "/", }); + public findOne = this.makeRequest<{ id: string }, OrganizationRepresentation>( + { + method: "GET", + path: "/{id}", + urlParamKeys: ["id"], + }, + ); + public create = this.makeRequest({ method: "POST", - path: "/organizations", + path: "/", returnResourceIdInLocationHeader: { field: "id" }, }); public delById = this.makeRequest<{ id: string }, void>({ method: "DELETE", - path: "/organizations/{id}", + path: "/{id}", urlParamKeys: ["id"], }); @@ -52,7 +67,62 @@ export class Organizations extends Resource<{ realm?: string }> { void >({ method: "PUT", - path: "/organizations/{id}", + path: "/{id}", urlParamKeys: ["id"], }); + + public listMembers = this.makeRequest({ + method: "GET", + path: "/{orgId}/members", + urlParamKeys: ["orgId"], + }); + + public addMember = this.makeRequest< + { orgId: string; userId: string }, + string + >({ + method: "POST", + path: "/{orgId}/members", + urlParamKeys: ["orgId"], + payloadKey: "userId", + }); + + public delMember = this.makeRequest< + { orgId: string; userId: string }, + string + >({ + method: "DELETE", + path: "/{orgId}/members/{userId}", + urlParamKeys: ["orgId", "userId"], + }); + + public invite = this.makeUpdateRequest<{ orgId: string }, FormData>({ + method: "POST", + path: "/{orgId}/members/invite-user", + urlParamKeys: ["orgId"], + }); + + public listIdentityProviders = this.makeRequest< + { orgId: string }, + IdentityProviderRepresentation[] + >({ + method: "GET", + path: "/{orgId}/identity-providers", + urlParamKeys: ["orgId"], + }); + + public linkIdp = this.makeRequest<{ orgId: string; alias: string }, string>({ + method: "POST", + path: "/{orgId}/identity-providers", + urlParamKeys: ["orgId"], + payloadKey: "alias", + }); + + public unLinkIdp = this.makeRequest<{ orgId: string; alias: string }, string>( + { + method: "DELETE", + path: "/{orgId}/identity-providers/{alias}", + urlParamKeys: ["orgId", "alias"], + }, + ); } diff --git a/model/jpa/src/main/java/org/keycloak/models/jpa/GroupAdapter.java b/model/jpa/src/main/java/org/keycloak/models/jpa/GroupAdapter.java index 436e1f279d11..4584b73f6342 100755 --- a/model/jpa/src/main/java/org/keycloak/models/jpa/GroupAdapter.java +++ b/model/jpa/src/main/java/org/keycloak/models/jpa/GroupAdapter.java @@ -39,6 +39,8 @@ import java.util.stream.Stream; import jakarta.persistence.LockModeType; +import static java.util.Optional.ofNullable; +import static org.keycloak.common.util.CollectionUtil.collectionEquals; import static org.keycloak.models.jpa.PaginationUtils.paginateQuery; import static org.keycloak.utils.StreamsUtil.closing; @@ -190,6 +192,12 @@ public void setSingleAttribute(String name, String value) { @Override public void setAttribute(String name, List values) { + List current = getAttributes().getOrDefault(name, List.of()); + + if (collectionEquals(current, ofNullable(values).orElse(List.of()))) { + return; + } + // Remove all existing removeAttribute(name); diff --git a/model/jpa/src/main/java/org/keycloak/organization/jpa/OrganizationAdapter.java b/model/jpa/src/main/java/org/keycloak/organization/jpa/OrganizationAdapter.java index 5da7af807727..2921c0b4d50d 100644 --- a/model/jpa/src/main/java/org/keycloak/organization/jpa/OrganizationAdapter.java +++ b/model/jpa/src/main/java/org/keycloak/organization/jpa/OrganizationAdapter.java @@ -17,6 +17,9 @@ package org.keycloak.organization.jpa; +import static java.util.Optional.ofNullable; + +import java.util.HashMap; import java.util.HashSet; import java.util.Map; import java.util.List; @@ -47,6 +50,7 @@ public final class OrganizationAdapter implements OrganizationModel, JpaModel> attributes; public OrganizationAdapter(RealmModel realm, OrganizationProvider provider) { entity = new OrganizationEntity(); @@ -114,6 +118,8 @@ public void setAttributes(Map> attributes) { if (attributes == null) { return; } + // make sure the kc.org attribute is never removed or updated + attributes.put(ORGANIZATION_ATTRIBUTE, getGroup().getAttributes().get(OrganizationModel.ORGANIZATION_ATTRIBUTE)); Set attrsToRemove = getAttributes().keySet(); attrsToRemove.removeAll(attributes.keySet()); attrsToRemove.forEach(group::removeAttribute); @@ -122,7 +128,12 @@ public void setAttributes(Map> attributes) { @Override public Map> getAttributes() { - return getGroup().getAttributes(); + if (attributes == null) { + attributes = new HashMap<>(ofNullable(getGroup().getAttributes()).orElse(Map.of())); + // do not expose the kc.org attribute + attributes.remove(OrganizationModel.ORGANIZATION_ATTRIBUTE); + } + return attributes; } @Override diff --git a/server-spi-private/src/main/java/org/keycloak/models/utils/RepresentationToModel.java b/server-spi-private/src/main/java/org/keycloak/models/utils/RepresentationToModel.java index b17a1f5f0e61..4586bb4e1199 100755 --- a/server-spi-private/src/main/java/org/keycloak/models/utils/RepresentationToModel.java +++ b/server-spi-private/src/main/java/org/keycloak/models/utils/RepresentationToModel.java @@ -126,6 +126,7 @@ import org.keycloak.representations.idm.authorization.ScopeRepresentation; import org.keycloak.storage.DatastoreProvider; import org.keycloak.util.JsonSerialization; +import org.keycloak.utils.StringUtil; import static org.keycloak.protocol.saml.util.ArtifactBindingUtils.computeArtifactBindingIdentifierString; @@ -1667,7 +1668,9 @@ private static void updateOrganizationBroker(RealmModel realm, IdentityProviderR String domain = representation.getConfig().get(OrganizationModel.ORGANIZATION_DOMAIN_ATTRIBUTE); - if (domain != null && org.getDomains().map(OrganizationDomainModel::getName).noneMatch(domain::equals)) { + if (StringUtil.isBlank(domain)) { + representation.getConfig().remove(OrganizationModel.ORGANIZATION_DOMAIN_ATTRIBUTE); + } else if (org.getDomains().map(OrganizationDomainModel::getName).noneMatch(domain::equals)) { throw new IllegalArgumentException("Domain does not match any domain from the organization"); } diff --git a/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationMemberResource.java b/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationMemberResource.java index 081bed8cebda..f27310cdb792 100644 --- a/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationMemberResource.java +++ b/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationMemberResource.java @@ -112,8 +112,8 @@ public Response addMember(String id) { @Operation(summary = "Invites an existing user or sends a registration link to a new user, based on the provided e-mail address.", description = "If the user with the given e-mail address exists, it sends an invitation link, otherwise it sends a registration link.") public Response inviteUser(@FormParam("email") String email, - @FormParam("first-name") String firstName, - @FormParam("last-name") String lastName) { + @FormParam("firstName") String firstName, + @FormParam("lastName") String lastName) { return new OrganizationInvitationResource(session, organization, adminEvent).inviteUser(email, firstName, lastName); } diff --git a/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationResource.java b/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationResource.java index 51796e8ae5f6..6b77ebc00fb3 100644 --- a/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationResource.java +++ b/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationResource.java @@ -31,10 +31,12 @@ import org.eclipse.microprofile.openapi.annotations.tags.Tag; import org.jboss.resteasy.reactive.NoCache; import org.keycloak.models.KeycloakSession; +import org.keycloak.models.ModelValidationException; import org.keycloak.models.OrganizationModel; import org.keycloak.organization.OrganizationProvider; import org.keycloak.organization.utils.Organizations; import org.keycloak.representations.idm.OrganizationRepresentation; +import org.keycloak.services.ErrorResponse; import org.keycloak.services.resources.KeycloakOpenAPI; import org.keycloak.services.resources.admin.AdminEventBuilder; @@ -81,8 +83,12 @@ public Response delete() { @Tag(name = KeycloakOpenAPI.Admin.Tags.ORGANIZATIONS) @Operation(summary = "Updates the organization") public Response update(OrganizationRepresentation organizationRep) { - Organizations.toModel(organizationRep, organization); - return Response.noContent().build(); + try { + Organizations.toModel(organizationRep, organization); + return Response.noContent().build(); + } catch (ModelValidationException mve) { + throw ErrorResponse.error(mve.getMessage(), Response.Status.BAD_REQUEST); + } } @Path("members") diff --git a/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationsResource.java b/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationsResource.java index edd15a464dd7..449b3066a121 100644 --- a/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationsResource.java +++ b/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationsResource.java @@ -41,6 +41,7 @@ import org.eclipse.microprofile.openapi.annotations.tags.Tag; import org.jboss.resteasy.reactive.NoCache; import org.keycloak.models.KeycloakSession; +import org.keycloak.models.ModelValidationException; import org.keycloak.models.OrganizationModel; import org.keycloak.organization.OrganizationProvider; import org.keycloak.organization.utils.Organizations; @@ -96,11 +97,16 @@ public Response create(OrganizationRepresentation organization) { .map(OrganizationDomainRepresentation::getName) .filter(StringUtil::isNotBlank) .collect(Collectors.toSet()); - OrganizationModel model = provider.create(organization.getName(), domains); - Organizations.toModel(organization, model); + try { + OrganizationModel model = provider.create(organization.getName(), domains); - return Response.created(session.getContext().getUri().getAbsolutePathBuilder().path(model.getId()).build()).build(); + Organizations.toModel(organization, model); + + return Response.created(session.getContext().getUri().getAbsolutePathBuilder().path(model.getId()).build()).build(); + } catch (ModelValidationException mve) { + throw ErrorResponse.error(mve.getMessage(), Response.Status.BAD_REQUEST); + } } /** diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/AbstractOrganizationTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/AbstractOrganizationTest.java index 463f81517096..52be10cfbde4 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/AbstractOrganizationTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/AbstractOrganizationTest.java @@ -106,7 +106,7 @@ protected OrganizationRepresentation createOrganization(RealmResource realm, Str return createOrganization(realm, getCleanup(), name, brokerConfigFunction.apply(name).setUpIdentityProvider(), orgDomains); } - protected static OrganizationRepresentation createOrganization(RealmResource testRealm, TestCleanup testCleanup, String name, + protected OrganizationRepresentation createOrganization(RealmResource testRealm, TestCleanup testCleanup, String name, IdentityProviderRepresentation broker, String... orgDomains) { OrganizationRepresentation org = createRepresentation(name, orgDomains); String id; @@ -126,7 +126,7 @@ protected static OrganizationRepresentation createOrganization(RealmResource tes return org; } - protected static OrganizationRepresentation createRepresentation(String name, String... orgDomains) { + protected OrganizationRepresentation createRepresentation(String name, String... orgDomains) { OrganizationRepresentation org = new OrganizationRepresentation(); org.setName(name); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/OrganizationGroupTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/OrganizationGroupTest.java index 15124f3e4ff5..7ec1fe987db5 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/OrganizationGroupTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/OrganizationGroupTest.java @@ -24,12 +24,14 @@ import java.util.ArrayList; import java.util.List; +import java.util.Map; import jakarta.ws.rs.BadRequestException; import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.Response.Status; import org.junit.Test; import org.keycloak.admin.client.resource.GroupResource; +import org.keycloak.admin.client.resource.OrganizationResource; import org.keycloak.common.Profile.Feature; import org.keycloak.models.GroupModel; import org.keycloak.models.ModelValidationException; @@ -57,7 +59,10 @@ public void testManageOrgGroupsViaDifferentAPIs() { // create 5 organizations for (int i = 0; i < 5; i++) { OrganizationRepresentation expected = createOrganization("myorg" + i); - OrganizationRepresentation existing = testRealm().organizations().get(expected.getId()).toRepresentation(); + OrganizationResource organization = testRealm().organizations().get(expected.getId()); + expected.setAttributes(Map.of()); + organization.update(expected).close(); + OrganizationRepresentation existing = organization.toRepresentation(); orgIds.add(expected.getId()); assertNotNull(existing); } @@ -253,4 +258,11 @@ public void testManagingOrganizationGroupNotInOrganizationScope() { } }); } + + @Override + protected OrganizationRepresentation createRepresentation(String name, String... orgDomains) { + OrganizationRepresentation rep = super.createRepresentation(name, orgDomains); + rep.setAttributes(Map.of()); + return rep; + } } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/OrganizationIdentityProviderTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/OrganizationIdentityProviderTest.java index 7628c504d827..2e7e77ce3b5f 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/OrganizationIdentityProviderTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/OrganizationIdentityProviderTest.java @@ -22,6 +22,7 @@ import static org.junit.Assert.assertFalse; import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.nullValue; +import static org.junit.Assert.assertNotNull; import static org.keycloak.models.OrganizationModel.BROKER_PUBLIC; import static org.keycloak.models.OrganizationModel.ORGANIZATION_DOMAIN_ATTRIBUTE; @@ -80,6 +81,27 @@ public void testUpdate() { actual = idpResource.toRepresentation(); // the link to the organization should not change Assert.assertEquals(actual.getConfig().get(OrganizationModel.ORGANIZATION_ATTRIBUTE), organization.getId()); + + String domain = actual.getConfig().get(ORGANIZATION_DOMAIN_ATTRIBUTE); + + assertNotNull(domain); + actual.getConfig().put(ORGANIZATION_DOMAIN_ATTRIBUTE, " "); + idpResource.update(actual); + actual = idpResource.toRepresentation(); + // domain removed + Assert.assertNull(actual.getConfig().get(ORGANIZATION_DOMAIN_ATTRIBUTE)); + + actual.getConfig().put(ORGANIZATION_DOMAIN_ATTRIBUTE, domain); + idpResource.update(actual); + actual = idpResource.toRepresentation(); + // domain set again + Assert.assertNotNull(actual.getConfig().get(ORGANIZATION_DOMAIN_ATTRIBUTE)); + + actual.getConfig().remove(ORGANIZATION_DOMAIN_ATTRIBUTE); + idpResource.update(actual); + actual = idpResource.toRepresentation(); + // domain removed + Assert.assertNull(actual.getConfig().get(ORGANIZATION_DOMAIN_ATTRIBUTE)); } @Test