Provide an additional endpoint to allow sending both invitation and registration links depending on the email being associated with an user or not

Closes #29482

Signed-off-by: Martin Kanis <mkanis@redhat.com>
This commit is contained in:
Martin Kanis 2024-05-20 16:33:19 +02:00 committed by Pedro Igor
parent 7182bc2125
commit 97cd5f3b8d
8 changed files with 85 additions and 30 deletions

View file

@ -20,6 +20,7 @@ package org.keycloak.admin.client.resource;
import java.util.List;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.FormParam;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
@ -76,11 +77,13 @@ public interface OrganizationMembersResource {
@POST
@Path("invite-user")
@Consumes(MediaType.APPLICATION_JSON)
Response inviteUser(String email);
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
Response inviteUser(@FormParam("email") String email,
@FormParam("first-name") String firstName,
@FormParam("last-name") String lastName);
@POST
@Path("invite-existing-user")
@Consumes(MediaType.APPLICATION_JSON)
Response inviteExistingUser(String id);
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
Response inviteExistingUser(@FormParam("id") String id);
}

View file

@ -168,6 +168,10 @@ public class FreeMarkerEmailTemplateProvider implements EmailTemplateProvider {
Map<String, Object> attributes = new HashMap<>(this.attributes);
addLinkInfoIntoAttributes(link, expirationInMinutes, attributes);
attributes.put("organization", organization);
if (user.getFirstName() != null && user.getLastName() != null) {
attributes.put("firstName", user.getFirstName());
attributes.put("lastName", user.getLastName());
}
send("orgInviteSubject", List.of(organization.getName()), "org-invite.ftl", attributes);
}

View file

@ -66,7 +66,7 @@ public class OrganizationInvitationResource {
this.tokenExpiration = getTokenExpiration();
}
public Response inviteUser(String email) {
public Response inviteUser(String email, String firstName, String lastName) {
if (StringUtil.isBlank(email)) {
throw ErrorResponse.error("To invite a member you need to provide an email", Status.BAD_REQUEST);
}
@ -76,15 +76,19 @@ public class OrganizationInvitationResource {
if (user != null) {
OrganizationModel org = provider.getByMember(user);
if (org.equals(organization)) {
if (org != null && org.equals(organization)) {
throw ErrorResponse.error("User already a member of the organization", Status.CONFLICT);
}
throw ErrorResponse.error("User already exists with this e-mail", Status.BAD_REQUEST);
return sendInvitation(user);
}
user = new InMemoryUserAdapter(session, realm, null);
user.setEmail(email);
if (firstName != null && lastName != null) {
user.setFirstName(firstName);
user.setLastName(lastName);
}
return sendInvitation(user);
}

View file

@ -22,6 +22,7 @@ import java.util.stream.Stream;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.FormParam;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.NotFoundException;
import jakarta.ws.rs.POST;
@ -111,18 +112,22 @@ public class OrganizationMemberResource {
@Path("invite-user")
@POST
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
@Tag(name = KeycloakOpenAPI.Admin.Tags.ORGANIZATIONS)
@Operation(summary = "Invites a new user to the organization using the specified e-mail address")
public Response inviteUser(String email) {
return new OrganizationInvitationResource(session, organization, adminEvent).inviteUser(email);
@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) {
return new OrganizationInvitationResource(session, organization, adminEvent).inviteUser(email, firstName, lastName);
}
@POST
@Path("invite-existing-user")
@Consumes(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
@Tag(name = KeycloakOpenAPI.Admin.Tags.ORGANIZATIONS)
@Operation(summary = "Invites an existing user to the organization, using the specified user id")
public Response inviteExistingUser(String id) {
public Response inviteExistingUser(@FormParam("id") String id) {
return new OrganizationInvitationResource(session, organization, adminEvent).inviteExistingUser(id);
}

View file

@ -29,6 +29,7 @@ import java.util.concurrent.TimeUnit;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
import jakarta.ws.rs.core.Response;
import org.hamcrest.Matchers;
import org.jboss.arquillian.graphene.page.Page;
import org.junit.Rule;
import org.junit.Test;
@ -87,19 +88,26 @@ public class OrganizationInvitationLinkTest extends AbstractOrganizationTest {
organization.members().inviteExistingUser(user.getId()).close();
MimeMessage message = greenMail.getLastReceivedMessage();
Assert.assertNotNull(message);
Assert.assertEquals("Invitation to join the " + organizationName + " organization", message.getSubject());
EmailBody body = MailUtils.getBody(message);
String link = MailUtils.getLink(body.getHtml());
driver.navigate().to(link.trim());
// not yet a member
Assert.assertFalse(organization.members().getAll().stream().anyMatch(actual -> user.getId().equals(actual.getId())));
// confirm the intent of membership
infoPage.clickToContinue();
assertThat(infoPage.getInfo(), containsString("Your account has been updated."));
// now a member
Assert.assertNotNull(organization.members().member(user.getId()).toRepresentation());
acceptInvitation(organization, user);
}
@Test
public void testInviteExistingUserWithEmail() throws IOException, MessagingException {
UserRepresentation user = UserBuilder.create()
.username("invitedWithMatchingEmail")
.email("invitedWithMatchingEmail@myemail.com")
.password("password")
.enabled(true)
.build();
try (Response response = testRealm().users().create(user)) {
user.setId(ApiUtil.getCreatedId(response));
}
OrganizationResource organization = testRealm().organizations().get(createOrganization().getId());
organization.members().inviteUser(user.getEmail(), "Homer", "Simpson").close();
acceptInvitation(organization, user);
}
@Test
@ -111,7 +119,7 @@ public class OrganizationInvitationLinkTest extends AbstractOrganizationTest {
.build();
// User isn't created when we send the invite
OrganizationResource organization = testRealm().organizations().get(createOrganization().getId());
organization.members().inviteUser(user.getEmail()).close();
organization.members().inviteUser(user.getEmail(), null, null).close();
MimeMessage message = greenMail.getLastReceivedMessage();
Assert.assertNotNull(message);
@ -150,7 +158,7 @@ public class OrganizationInvitationLinkTest extends AbstractOrganizationTest {
.build();
// User isn't created when we send the invite
OrganizationResource organization = testRealm().organizations().get(createOrganization().getId());
organization.members().inviteUser(user.getEmail()).close();
organization.members().inviteUser(user.getEmail(), null, null).close();
MimeMessage message = greenMail.getLastReceivedMessage();
Assert.assertNotNull(message);
@ -178,7 +186,7 @@ public class OrganizationInvitationLinkTest extends AbstractOrganizationTest {
.build();
// User isn't created when we send the invite
OrganizationResource organization = testRealm().organizations().get(createOrganization().getId());
organization.members().inviteUser(user.getEmail()).close();
organization.members().inviteUser(user.getEmail(), "Homer", "Simpson").close();
try {
setTimeOffset((int) TimeUnit.DAYS.toSeconds(1));
@ -201,4 +209,23 @@ public class OrganizationInvitationLinkTest extends AbstractOrganizationTest {
resetTimeOffset();
}
}
private void acceptInvitation(OrganizationResource organization, UserRepresentation user) throws MessagingException, IOException {
MimeMessage message = greenMail.getLastReceivedMessage();
Assert.assertNotNull(message);
Assert.assertEquals("Invitation to join the " + organizationName + " organization", message.getSubject());
EmailBody body = MailUtils.getBody(message);
if (user.getFirstName() != null && user.getLastName() != null) {
assertThat(body.getText(), Matchers.containsString(user.getFirstName() + " " + user.getLastName()));
}
String link = MailUtils.getLink(body.getHtml());
driver.navigate().to(link.trim());
// not yet a member
Assert.assertFalse(organization.members().getAll().stream().anyMatch(actual -> user.getId().equals(actual.getId())));
// confirm the intent of membership
infoPage.clickToContinue();
assertThat(infoPage.getInfo(), containsString("Your account has been updated."));
// now a member
Assert.assertNotNull(organization.members().member(user.getId()).toRepresentation());
}
}

View file

@ -1,4 +1,8 @@
<#import "template.ftl" as layout>
<@layout.emailLayout>
${kcSanitize(msg("orgInviteBodyHtml", link, linkExpiration, realmName, organization.name, linkExpirationFormatter(linkExpiration)))?no_esc}
<#if firstName?? && lastName??>
${kcSanitize(msg("orgInviteBodyPersonalizedHtml", link, linkExpiration, realmName, organization.name, linkExpirationFormatter(linkExpiration), firstName, lastName))?no_esc}
<#else>
${kcSanitize(msg("orgInviteBodyHtml", link, linkExpiration, realmName, organization.name, linkExpirationFormatter(linkExpiration)))?no_esc}
</#if>
</@layout.emailLayout>

View file

@ -4,6 +4,8 @@ emailVerificationBodyHtml=<p>Someone has created a {2} account with this email a
orgInviteSubject=Invitation to join the {0} organization
orgInviteBody=You were invited to join the "{3}" organization. Click the link below to join.\n\n{0}\n\nThis link will expire within {4}.\n\nIf you don't want to join the organization, just ignore this message.
orgInviteBodyHtml=<p>You were invited to join the {3} organization. Click the link below to join. </p><p><a href="{0}">Link to join the organization</a></p><p>This link will expire within {4}.</p><p>If you don't want to join the organization, just ignore this message.</p>
orgInviteBodyPersonalized="{5}" "{6}"\n\n You were invited to join the "{3}" organization. Click the link below to join.\n\n{0}\n\nThis link will expire within {4}.\n\nIf you don't want to join the organization, just ignore this message.
orgInviteBodyPersonalizedHtml=<p>{5} {6}</p><p>You were invited to join the {3} organization. Click the link below to join. </p><p><a href="{0}">Link to join the organization</a></p><p>This link will expire within {4}.</p><p>If you don't want to join the organization, just ignore this message.</p>
emailUpdateConfirmationSubject=Verify new email
emailUpdateConfirmationBody=To update your {2} account with email address {1}, click the link below\n\n{0}\n\nThis link will expire within {3}.\n\nIf you don''t want to proceed with this modification, just ignore this message.
emailUpdateConfirmationBodyHtml=<p>To update your {2} account with email address {1}, click the link below</p><p><a href="{0}">{0}</a></p><p>This link will expire within {3}.</p><p>If you don''t want to proceed with this modification, just ignore this message.</p>

View file

@ -1,2 +1,8 @@
<#ftl output_format="plainText">
${kcSanitize(msg("orgInviteBody", link, linkExpiration, realmName, organization.name, linkExpirationFormatter(linkExpiration)))}
<#if firstName?? && lastName??>
${kcSanitize(msg("orgInviteBodyPersonalized", link, linkExpiration, realmName, organization.name, linkExpirationFormatter(linkExpiration), firstName, lastName))}
<#else>
${kcSanitize(msg("orgInviteBody", link, linkExpiration, realmName, organization.name, linkExpirationFormatter(linkExpiration)))}
</#if>