From 97cd5f3b8dc3920532903b28c5043e517d90961b Mon Sep 17 00:00:00 2001 From: Martin Kanis Date: Mon, 20 May 2024 16:33:19 +0200 Subject: [PATCH] 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 --- .../resource/OrganizationMembersResource.java | 11 ++-- .../FreeMarkerEmailTemplateProvider.java | 4 ++ .../OrganizationInvitationResource.java | 10 +++- .../resource/OrganizationMemberResource.java | 15 +++-- .../admin/OrganizationInvitationLinkTest.java | 59 ++++++++++++++----- .../theme/base/email/html/org-invite.ftl | 6 +- .../email/messages/messages_en.properties | 2 + .../theme/base/email/text/org-invite.ftl | 8 ++- 8 files changed, 85 insertions(+), 30 deletions(-) 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 a5271e1cb3..64b601608a 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 @@ -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); } diff --git a/services/src/main/java/org/keycloak/email/freemarker/FreeMarkerEmailTemplateProvider.java b/services/src/main/java/org/keycloak/email/freemarker/FreeMarkerEmailTemplateProvider.java index a5a93fb55c..8535e6bdc8 100755 --- a/services/src/main/java/org/keycloak/email/freemarker/FreeMarkerEmailTemplateProvider.java +++ b/services/src/main/java/org/keycloak/email/freemarker/FreeMarkerEmailTemplateProvider.java @@ -168,6 +168,10 @@ public class FreeMarkerEmailTemplateProvider implements EmailTemplateProvider { Map 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); } diff --git a/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationInvitationResource.java b/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationInvitationResource.java index c3069a4a23..1f670b18eb 100644 --- a/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationInvitationResource.java +++ b/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationInvitationResource.java @@ -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); } 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 8f3d6c52f1..5d96612356 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 @@ -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); } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/OrganizationInvitationLinkTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/OrganizationInvitationLinkTest.java index 6004fffbe8..b30000e334 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/OrganizationInvitationLinkTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/OrganizationInvitationLinkTest.java @@ -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()); + } } \ No newline at end of file diff --git a/themes/src/main/resources/theme/base/email/html/org-invite.ftl b/themes/src/main/resources/theme/base/email/html/org-invite.ftl index 78811f0eda..032c075b27 100644 --- a/themes/src/main/resources/theme/base/email/html/org-invite.ftl +++ b/themes/src/main/resources/theme/base/email/html/org-invite.ftl @@ -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} + diff --git a/themes/src/main/resources/theme/base/email/messages/messages_en.properties b/themes/src/main/resources/theme/base/email/messages/messages_en.properties index b40fe28de4..2e89968a79 100755 --- a/themes/src/main/resources/theme/base/email/messages/messages_en.properties +++ b/themes/src/main/resources/theme/base/email/messages/messages_en.properties @@ -4,6 +4,8 @@ emailVerificationBodyHtml=

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=

You were invited to join the {3} organization. Click the link below to join.

Link to join the organization

This link will expire within {4}.

If you don't want to join the organization, just ignore this message.

+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=

{5} {6}

You were invited to join the {3} organization. Click the link below to join.

Link to join the organization

This link will expire within {4}.

If you don't want to join the organization, just ignore this message.

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=

To update your {2} account with email address {1}, click the link below

{0}

This link will expire within {3}.

If you don''t want to proceed with this modification, just ignore this message.

diff --git a/themes/src/main/resources/theme/base/email/text/org-invite.ftl b/themes/src/main/resources/theme/base/email/text/org-invite.ftl index b74abe0d0a..dcb320db6f 100644 --- a/themes/src/main/resources/theme/base/email/text/org-invite.ftl +++ b/themes/src/main/resources/theme/base/email/text/org-invite.ftl @@ -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)))} + +