Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package org.keycloak.representations.idm;

import java.util.List;
import java.util.Map;

/**
* Request body for inviting an existing user to an organization by user ID.
*/
public class OrganizationInvitationExistingUserRequest {

private String id;
private Map<String, List<String>> attributes;

public OrganizationInvitationExistingUserRequest() {
}

public String getId() {
return id;
}

public void setId(String id) {
this.id = id;
}

public Map<String, List<String>> getAttributes() {
return attributes;
}

public void setAttributes(Map<String, List<String>> attributes) {
this.attributes = attributes;
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
package org.keycloak.representations.idm;

import java.util.List;
import java.util.Map;

/**
* Representation of an organization invitation.
*/
Expand All @@ -19,6 +22,7 @@ public enum Status {
private int expiresAt;
private Status status;
private String inviteLink;
private Map<String, List<String>> attributes;

public OrganizationInvitationRepresentation() {
}
Expand Down Expand Up @@ -95,6 +99,14 @@ public void setInviteLink(String inviteLink) {
this.inviteLink = inviteLink;
}

public Map<String, List<String>> getAttributes() {
return attributes;
}

public void setAttributes(Map<String, List<String>> attributes) {
this.attributes = attributes;
}

@Override
public boolean equals(Object o) {
if (this == o) return true;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package org.keycloak.representations.idm;

import java.util.List;
import java.util.Map;

/**
* Request body for inviting a new or existing user to an organization by email.
*/
public class OrganizationInvitationUserRequest {

private String email;
private String firstName;
private String lastName;
private Map<String, List<String>> attributes;

public OrganizationInvitationUserRequest() {
}

public String getEmail() {
return email;
}

public void setEmail(String email) {
this.email = email;
}

public String getFirstName() {
return firstName;
}

public void setFirstName(String firstName) {
this.firstName = firstName;
}

public String getLastName() {
return lastName;
}

public void setLastName(String lastName) {
this.lastName = lastName;
}

public Map<String, List<String>> getAttributes() {
return attributes;
}

public void setAttributes(Map<String, List<String>> attributes) {
this.attributes = attributes;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@

import org.keycloak.representations.idm.MemberRepresentation;
import org.keycloak.representations.idm.MembershipType;
import org.keycloak.representations.idm.OrganizationInvitationExistingUserRequest;
import org.keycloak.representations.idm.OrganizationInvitationUserRequest;
import org.keycloak.representations.idm.OrganizationRepresentation;

public interface OrganizationMembersResource {
Expand Down Expand Up @@ -193,11 +195,21 @@ Response inviteUser(@FormParam("email") String email,
@FormParam("firstName") String firstName,
@FormParam("lastName") String lastName);

@POST
@Path("invite-user")
@Consumes(MediaType.APPLICATION_JSON)
Response inviteUser(OrganizationInvitationUserRequest req);

@POST
@Path("invite-existing-user")
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
Response inviteExistingUser(@FormParam("id") String id);

@POST
@Path("invite-existing-user")
@Consumes(MediaType.APPLICATION_JSON)
Response inviteExistingUser(OrganizationInvitationExistingUserRequest req);

/**
* @since Keycloak server 26
* @return count of members of the organization
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3714,6 +3714,7 @@ organizationUsersLeft_one=User left the organization
organizationUsersLeft_other={{count}} users left the organization
inviteSent=Invitation has been sent.
inviteSentError=Could not send invitation\: {{error}}
invitationAttributes=Set invitation attributes
organizationInvitationsSent_one=Invitation sent to realm user
organizationInvitationsSent_other=Invitations sent to {{count}} realm users
organizationInvitationsSentError=Could not invite users to the organization\: {{error}}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { Button, Form, Modal, ModalVariant } from "@patternfly/react-core";
import { FormProvider, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { KeyValueInput } from "../components/key-value-form/KeyValueInput";
import {
KeyValueType,
keyValueToArray,
} from "../components/key-value-form/key-value-convert";

type InvitationAttributesModalProps = {
onSubmit: (attributes: Record<string, string[]>) => Promise<void>;
onClose: () => void;
};

type InvitationAttributesForm = {
attributes: KeyValueType[];
};

export const InvitationAttributesModal = ({
onSubmit,
onClose,
}: InvitationAttributesModalProps) => {
Comment thread
kota65535 marked this conversation as resolved.
const { t } = useTranslation();
const form = useForm<InvitationAttributesForm>();
const {
handleSubmit,
formState: { isSubmitting },
} = form;

const submitForm = async (data: InvitationAttributesForm) => {
const attributes = keyValueToArray(data.attributes);
await onSubmit(attributes);
};

return (
<Modal
variant={ModalVariant.small}
title={t("invitationAttributes")}
isOpen
onClose={onClose}
actions={[
<Button
data-testid="send"
key="confirm"
variant="primary"
onClick={handleSubmit(submitForm)}
isDisabled={isSubmitting}
isLoading={isSubmitting}
>
{t("send")}
</Button>,
<Button
data-testid="cancel"
key="cancel"
variant="link"
onClick={onClose}
>
{t("cancel")}
</Button>,
]}
>
<FormProvider {...form}>
<Form id="invitation-attributes-form">
<KeyValueInput name="attributes" />
</Form>
</FormProvider>
</Modal>
);
};
72 changes: 58 additions & 14 deletions js/apps/admin-ui/src/organizations/Invitations.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ import {
DropdownList,
MenuToggle,
ToolbarItem,
Tooltip,
} from "@patternfly/react-core";
import type UserRepresentation from "@keycloak/keycloak-admin-client/lib/defs/userRepresentation";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useAdminClient } from "../admin-client";
Expand All @@ -19,6 +21,7 @@ import { KeycloakDataTable } from "@keycloak/keycloak-ui-shared";
import { useParams } from "../utils/useParams";
import useToggle from "../utils/useToggle";
import { InviteMemberModal } from "./InviteMemberModal";
import { InvitationAttributesModal } from "./InvitationAttributesModal";
import { MemberModal } from "../groups/MembersModal";
import { EditOrganizationParams } from "./routes/EditOrganization";
import { SearchInputComponent } from "../components/dynamic/SearchInputComponent";
Expand Down Expand Up @@ -70,6 +73,10 @@ export const Invitations = () => {
const [searchTriggerText, setSearchTriggerText] = useState<string>("");
const [filteredStatuses, setFilteredStatuses] = useState<string[]>([]);
const [isStatusFilterOpen, setIsStatusFilterOpen] = useState(false);
const [pendingInviteUsers, setPendingInviteUsers] = useState<
UserRepresentation[]
>([]);
const [showAttributesModal, setShowAttributesModal] = useState(false);

const statusOptions = Object.values(OrganizationInvitationStatus).map(
(status: string) => ({
Expand Down Expand Up @@ -180,34 +187,51 @@ export const Invitations = () => {
)}
{openInviteRealmUser && (
<MemberModal
titleKey="inviteRealmUser"
confirmLabelKey="send"
filterEmptyEmail
membersQuery={() => adminClient.organizations.listMembers({ orgId })}
onAdd={async (selectedRows) => {
filterEmptyEmail
titleKey="inviteRealmUser"
confirmLabelKey="next"
onAdd={async (users) => {
setPendingInviteUsers(users);
setShowAttributesModal(true);
}}
onClose={() => {
toggleInviteRealmUser();
}}
/>
)}
{showAttributesModal && (
<InvitationAttributesModal
onSubmit={async (attributes) => {
try {
const hasAttributes = Object.keys(attributes).length > 0;
await Promise.all(
selectedRows.map((user) => {
const form = new FormData();
form.append("id", user.id!);
return adminClient.organizations.inviteExistingUser(
pendingInviteUsers.map((user) =>
adminClient.organizations.inviteExistingUser(
{ orgId },
form,
);
}),
{
id: user.id!,
attributes: hasAttributes ? attributes : undefined,
},
),
),
);
addAlert(
t("organizationInvitationsSent", {
count: selectedRows.length,
count: pendingInviteUsers.length,
}),
);
refresh();
} catch (error) {
addError("organizationInvitationsSentError", error);
} finally {
setShowAttributesModal(false);
setPendingInviteUsers([]);
}
}}
onClose={() => {
toggleInviteRealmUser();
refresh();
setShowAttributesModal(false);
setPendingInviteUsers([]);
}}
/>
)}
Expand Down Expand Up @@ -359,6 +383,26 @@ export const Invitations = () => {
<InvitationStatusBadge status={invitation.status} />
),
},
{
name: "attributes",
displayKey: "attributes",
cellRenderer: (invitation) => {
const attrs = invitation.attributes;
if (!attrs || Object.keys(attrs).length === 0) {
return "-";
}
const tooltipContent = Object.entries(attrs)
.map(([key, values]) => `${key}: ${values.join(", ")}`)
.join("\n");
return (
<Tooltip content={<pre>{tooltipContent}</pre>}>
<span className="pf-v5-u-display-block" tabIndex={0}>
{Object.keys(attrs).length}
</span>
</Tooltip>
);
},
},
]}
emptyState={
<ListEmptyState
Expand Down
Loading
Loading