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 f735d0e9f84..4c43e749538 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
@@ -3216,4 +3216,8 @@ emailVerificationHelp=Specifies independent timeout for email verification.
idpAccountEmailVerificationHelp=Specifies independent timeout for IdP account email verification.
forgotPasswordHelp=Specifies independent timeout for forgot password.
executeActionsHelp=Specifies independent timeout for execute actions.
-validatingX509CertsHelp=The public certificates Keycloak uses to validate the signatures of SAML requests and responses from the external IDP when Use metadata descriptor URL is OFF. Multiple certificates can be entered separated by comma (,). The certificates can be re-imported from the Metadata descriptor URL clicking the Import Keys action in the identity provider page. The action downloads the current certificates in the metadata endpoint and assigns them to the config in this same option. You need to click Save to definitely store the re-imported certificates.
\ No newline at end of file
+validatingX509CertsHelp=The public certificates Keycloak uses to validate the signatures of SAML requests and responses from the external IDP when Use metadata descriptor URL is OFF. Multiple certificates can be entered separated by comma (,). The certificates can be re-imported from the Metadata descriptor URL clicking the Import Keys action in the identity provider page. The action downloads the current certificates in the metadata endpoint and assigns them to the config in this same option. You need to click Save to definitely store the re-imported certificates.
+emptyUserOrganizations=No organizations
+emptyUserOrganizationsInstructions=There is no organization yet. Please join an organization or send an invitation to join an organization.
+joinOrganization=Join organization
+sendInvitation=Send invitation
\ No newline at end of file
diff --git a/js/apps/admin-ui/src/organizations/OrganizationTable.tsx b/js/apps/admin-ui/src/organizations/OrganizationTable.tsx
new file mode 100644
index 00000000000..ab31eacc40e
--- /dev/null
+++ b/js/apps/admin-ui/src/organizations/OrganizationTable.tsx
@@ -0,0 +1,107 @@
+import OrganizationRepresentation from "@keycloak/keycloak-admin-client/lib/defs/organizationRepresentation";
+import { Badge, Chip, ChipGroup } from "@patternfly/react-core";
+import { TableText } from "@patternfly/react-table";
+import { PropsWithChildren, ReactNode } from "react";
+import { useTranslation } from "react-i18next";
+import { Link } from "react-router-dom";
+import {
+ KeycloakDataTable,
+ LoaderFunction,
+} from "../components/table-toolbar/KeycloakDataTable";
+import { useRealm } from "../context/realm-context/RealmContext";
+import { toEditOrganization } from "./routes/EditOrganization";
+
+const OrgDetailLink = (organization: OrganizationRepresentation) => {
+ 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}
+
+ ))}
+
+ );
+};
+
+type OrganizationTableProps = PropsWithChildren & {
+ loader:
+ | LoaderFunction
+ | OrganizationRepresentation[];
+ onDelete?: (org: OrganizationRepresentation) => void;
+ toolbarItem?: ReactNode;
+};
+
+export const OrganizationTable = ({
+ loader,
+ toolbarItem,
+ onDelete,
+ children,
+}: OrganizationTableProps) => {
+ const { t } = useTranslation();
+
+ return (
+
+ );
+};
diff --git a/js/apps/admin-ui/src/organizations/OrganizationsSection.tsx b/js/apps/admin-ui/src/organizations/OrganizationsSection.tsx
index 678587e6447..563163b0231 100644
--- a/js/apps/admin-ui/src/organizations/OrganizationsSection.tsx
+++ b/js/apps/admin-ui/src/organizations/OrganizationsSection.tsx
@@ -1,71 +1,21 @@
import OrganizationRepresentation from "@keycloak/keycloak-admin-client/lib/defs/organizationRepresentation";
+import { useAlerts } from "@keycloak/keycloak-ui-shared";
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 "@keycloak/keycloak-ui-shared";
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 { OrganizationTable } from "./OrganizationTable";
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();
@@ -110,12 +60,9 @@ export default function OrganizationSection() {
/>
-
>
);
diff --git a/js/apps/admin-ui/src/user/EditUser.tsx b/js/apps/admin-ui/src/user/EditUser.tsx
index f0823eedd1f..ed90845ae19 100644
--- a/js/apps/admin-ui/src/user/EditUser.tsx
+++ b/js/apps/admin-ui/src/user/EditUser.tsx
@@ -5,6 +5,7 @@ import type {
import {
isUserProfileError,
setUserProfileServerError,
+ useAlerts,
} from "@keycloak/keycloak-ui-shared";
import {
AlertVariant,
@@ -23,7 +24,6 @@ import { FormProvider, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { useAdminClient } from "../admin-client";
-import { useAlerts } from "@keycloak/keycloak-ui-shared";
import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog";
import { KeyValueType } from "../components/key-value-form/key-value-convert";
import { KeycloakSpinner } from "../components/keycloak-spinner/KeycloakSpinner";
@@ -36,7 +36,9 @@ import { useAccess } from "../context/access/Access";
import { useRealm } from "../context/realm-context/RealmContext";
import { UserProfileProvider } from "../realm-settings/user-profile/UserProfileContext";
import { useFetch } from "../utils/useFetch";
+import useIsFeatureEnabled, { Feature } from "../utils/useIsFeatureEnabled";
import { useParams } from "../utils/useParams";
+import { Organizations } from "./Organizations";
import { UserAttributes } from "./UserAttributes";
import { UserConsents } from "./UserConsents";
import { UserCredentials } from "./UserCredentials";
@@ -87,6 +89,11 @@ export default function EditUser() {
const lightweightUser = isLightweightUser(user?.id);
const [upConfig, setUpConfig] = useState();
+ const [realmHasOrganizations, setRealmHasOrganizations] = useState(false);
+ const isFeatureEnabled = useIsFeatureEnabled();
+ const showOrganizations =
+ isFeatureEnabled(Feature.Organizations) && realm?.organizationsEnabled;
+
const toTab = (tab: UserTab) =>
toUser({
realm: realmName,
@@ -101,6 +108,7 @@ export default function EditUser() {
const credentialsTab = useTab("credentials");
const roleMappingTab = useTab("role-mapping");
const groupsTab = useTab("groups");
+ const organizationsTab = useTab("organizations");
const consentsTab = useTab("consents");
const identityProviderLinksTab = useTab("identity-provider-links");
const sessionsTab = useTab("sessions");
@@ -115,8 +123,15 @@ export default function EditUser() {
adminClient.attackDetection.findOne({ id: id! }),
adminClient.users.getUnmanagedAttributes({ id: id! }),
adminClient.users.getProfile({ realm: realmName }),
+ adminClient.organizations.find({ first: 0, max: 1 }),
]),
- ([userData, attackDetection, unmanagedAttributes, upConfig]) => {
+ ([
+ userData,
+ attackDetection,
+ unmanagedAttributes,
+ upConfig,
+ organizations,
+ ]) => {
if (!userData || !realm || !attackDetection) {
throw new Error(t("notFound"));
}
@@ -140,6 +155,7 @@ export default function EditUser() {
const isLocked = isBruteForceProtected && attackDetection.disabled;
setBruteForced({ isBruteForceProtected, isLocked });
+ setRealmHasOrganizations(organizations.length === 1);
form.reset(toUserFormFields(user));
},
@@ -357,6 +373,15 @@ export default function EditUser() {
)}
+ {showOrganizations && realmHasOrganizations && (
+ {t("organizations")}}
+ {...organizationsTab}
+ >
+
+
+ )}
{t("consents")}}
diff --git a/js/apps/admin-ui/src/user/Organizations.tsx b/js/apps/admin-ui/src/user/Organizations.tsx
new file mode 100644
index 00000000000..d6257ee179a
--- /dev/null
+++ b/js/apps/admin-ui/src/user/Organizations.tsx
@@ -0,0 +1,66 @@
+import { Button, ToolbarItem } from "@patternfly/react-core";
+import { useTranslation } from "react-i18next";
+import { Link, useParams } from "react-router-dom";
+import { useAdminClient } from "../admin-client";
+import { ListEmptyState } from "../components/list-empty-state/ListEmptyState";
+import { useRealm } from "../context/realm-context/RealmContext";
+import { OrganizationTable } from "../organizations/OrganizationTable";
+import { toAddOrganization } from "../organizations/routes/AddOrganization";
+import { UserParams } from "./routes/User";
+
+export const Organizations = () => {
+ const { adminClient } = useAdminClient();
+ const { t } = useTranslation();
+ const { realm } = useRealm();
+ const { id } = useParams();
+
+ return (
+
+ adminClient.organizations.memberOrganizations({
+ userId: id!,
+ })
+ }
+ toolbarItem={
+ <>
+
+
+
+
+
+
+ >
+ }
+ >
+ alert("join organization"),
+ },
+ {
+ text: t("sendInvitation"),
+ onClick: () => alert("send invitation"),
+ },
+ ]}
+ />
+
+ );
+};
diff --git a/js/apps/admin-ui/src/user/routes/User.tsx b/js/apps/admin-ui/src/user/routes/User.tsx
index 808e42ef9d1..f72ae3c23f6 100644
--- a/js/apps/admin-ui/src/user/routes/User.tsx
+++ b/js/apps/admin-ui/src/user/routes/User.tsx
@@ -6,6 +6,7 @@ import type { AppRouteObject } from "../../routes";
export type UserTab =
| "settings"
| "groups"
+ | "organizations"
| "consents"
| "attributes"
| "sessions"
diff --git a/js/apps/keycloak-server/scripts/start-server.js b/js/apps/keycloak-server/scripts/start-server.js
index d1cf03a931c..b3e08bba516 100755
--- a/js/apps/keycloak-server/scripts/start-server.js
+++ b/js/apps/keycloak-server/scripts/start-server.js
@@ -56,7 +56,7 @@ async function startServer() {
path.join(SERVER_DIR, `bin/kc${SCRIPT_EXTENSION}`),
[
"start-dev",
- `--features="login2,account3,admin-fine-grained-authz,transient-users,oid4vc-vci"`,
+ `--features="login2,account3,admin-fine-grained-authz,transient-users,oid4vc-vci,organization"`,
...keycloakArgs,
],
{
diff --git a/js/libs/keycloak-admin-client/src/resources/organizations.ts b/js/libs/keycloak-admin-client/src/resources/organizations.ts
index 8ca0c9fce9d..3a07e175375 100644
--- a/js/libs/keycloak-admin-client/src/resources/organizations.ts
+++ b/js/libs/keycloak-admin-client/src/resources/organizations.ts
@@ -96,6 +96,15 @@ export class Organizations extends Resource<{ realm?: string }> {
urlParamKeys: ["orgId", "userId"],
});
+ public memberOrganizations = this.makeRequest<
+ { userId: string },
+ OrganizationRepresentation[]
+ >({
+ method: "GET",
+ path: "/members/{userId}/organizations",
+ urlParamKeys: ["userId"],
+ });
+
public invite = this.makeUpdateRequest<{ orgId: string }, FormData>({
method: "POST",
path: "/{orgId}/members/invite-user",